Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions src/dotnet/skills/migrating-newtonsoft-to-system-text-json/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
```skill
---
name: migrating-newtonsoft-to-system-text-json
description: Migrate from Newtonsoft.Json to System.Text.Json, handling behavioral differences, custom converters, and common breaking changes. Use when converting a project from Newtonsoft.Json (Json.NET) to the built-in System.Text.Json serializer.
---
Comment on lines +1 to +5

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skill metadata format is incorrect. Skills in this repository use standard YAML frontmatter delimited by --- (triple dashes), not code fence blocks with ```skill.

The skill-validator expects frontmatter in this format:

---
name: migrating-newtonsoft-to-system-text-json
description: ...
---

The current ```skill format will not be parsed correctly by the discovery system (see eng/skill-validator/src/discovery.ts lines 11-16 which specifically looks for the --- delimited frontmatter pattern).

Copilot uses AI. Check for mistakes.

# Migrating from Newtonsoft.Json to System.Text.Json

## When to Use

- Migrating an existing project from Newtonsoft.Json to System.Text.Json
- Removing the Newtonsoft.Json dependency for performance or AOT compatibility
- Fixing serialization differences after switching to System.Text.Json

## When Not to Use

- The project requires Newtonsoft.Json features that System.Text.Json cannot support (extremely rare edge cases like `$ref/$id` with deep graphs)
- The user is already using System.Text.Json and just needs help with it
- The user explicitly wants to keep Newtonsoft.Json

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| Code using Newtonsoft.Json | Yes | Models, serialization calls, custom converters |
| .NET version | No | Determines which System.Text.Json features are available |

## Workflow

### Step 1: Understand the critical behavioral differences

**System.Text.Json is NOT a drop-in replacement.** These behaviors differ by default:

| Behavior | Newtonsoft.Json | System.Text.Json | Impact |
|----------|----------------|-------------------|--------|
| **Property naming** | camelCase by default | **PascalCase by default** | APIs will return different JSON |
Comment thread
mrsharm marked this conversation as resolved.
Outdated
| **Missing properties** | Ignored silently | Ignored silently | Same ✓ |
| **Extra JSON properties** | Ignored by default | **Throws by default (.NET 8+)** | Deserialization breaks! |
Comment thread
mrsharm marked this conversation as resolved.
Outdated
| **Trailing commas** | Allowed | **Rejected by default** | Parse errors on valid-looking JSON |
| **Comments in JSON** | Allowed | **Rejected by default** | Config files break |
| **Number in string** (`"123"`) | Coerced automatically | **Throws by default** | Deserialization breaks! |
| **Enum serialization** | Numeric by default | Numeric by default | Same ✓, but converter syntax differs |
| **null → non-nullable value type** | Sets to default(T) | **Throws exception** | Breaks on dirty data |
Comment thread
mrsharm marked this conversation as resolved.
Outdated
| **Case sensitivity** | Case-insensitive | **Case-sensitive by default** | Property matching breaks |

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim that Newtonsoft.Json is "case-insensitive" by default is incorrect. Newtonsoft.Json is case-sensitive by default during deserialization, just like System.Text.Json.

Both libraries require explicit configuration to enable case-insensitive property matching (Newtonsoft uses MissingMemberHandling or custom settings, System.Text.Json uses PropertyNameCaseInsensitive = true).

This row should be corrected to indicate that both libraries are case-sensitive by default.

Suggested change
| **Case sensitivity** | Case-insensitive | **Case-sensitive by default** | Property matching breaks |
| **Case sensitivity** | **Case-sensitive by default** | **Case-sensitive by default** | Same by default; configure explicitly for case-insensitive matching |

Copilot uses AI. Check for mistakes.
| **Max depth** | 64 | 64 | Same ✓ |
| **Circular references** | `$ref/$id` with PreserveReferencesHandling | `ReferenceHandler.Preserve` (.NET 5+) | API differs |

### Step 2: Configure System.Text.Json to match Newtonsoft.Json behavior

```csharp
// In Program.cs (ASP.NET Core) — configure globally
builder.Services.ConfigureHttpJsonOptions(options =>
{
ConfigureJsonOptions(options.SerializerOptions);
});

// Also configure for controllers if using MVC
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
ConfigureJsonOptions(options.JsonSerializerOptions);
});

static void ConfigureJsonOptions(JsonSerializerOptions options)
{
// Match Newtonsoft.Json default behavior:
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; // Newtonsoft default

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Newtonsoft default" on line 67 is incorrect. Newtonsoft.Json does not use camelCase by default for property naming. It uses the property names as-is (typically PascalCase for C# properties). CamelCase requires explicit configuration via CamelCasePropertyNamesContractResolver.

This comment should be removed or corrected to indicate this is a common convention, not a Newtonsoft default.

Copilot uses AI. Check for mistakes.
options.PropertyNameCaseInsensitive = true; // Newtonsoft default

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Newtonsoft default" on line 68 is misleading. PropertyNameCaseInsensitive is not a Newtonsoft.Json default behavior. Newtonsoft.Json is case-sensitive by default during deserialization, just like System.Text.Json.

This comment should be removed or changed to clarify that this is an optional configuration, not matching a Newtonsoft default.

Copilot uses AI. Check for mistakes.
options.NumberHandling = JsonNumberHandling.AllowReadingFromString; // Newtonsoft coerces
options.ReadCommentHandling = JsonCommentHandling.Skip; // Newtonsoft allows
options.AllowTrailingCommas = true; // Newtonsoft allows
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // Common Newtonsoft setting

// Enum string serialization (replaces StringEnumConverter)
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));

// Handle circular references (replaces PreserveReferencesHandling)
options.ReferenceHandler = ReferenceHandler.IgnoreCycles; // or Preserve for $ref/$id
}
```

### Step 3: Replace attribute mappings

| Newtonsoft.Json Attribute | System.Text.Json Equivalent |
|--------------------------|----------------------------|
| `[JsonProperty("name")]` | `[JsonPropertyName("name")]` |
| `[JsonIgnore]` | `[JsonIgnore]` (same name, different namespace!) |
| `[JsonProperty(Required = Required.Always)]` | `[JsonRequired]` (.NET 7+) |
| `[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]` | `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` |
| `[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]` | `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]` |
| `[JsonConverter(typeof(MyConverter))]` | `[JsonConverter(typeof(MyConverter))]` (different base class!) |
| `[JsonConstructor]` | `[JsonConstructor]` (same name, different namespace) |
| `[JsonExtensionData]` | `[JsonExtensionData]` + must be `Dictionary<string, JsonElement>` (NOT `JToken`) |

**Regex for finding Newtonsoft attributes:**
```bash
# Find all files using Newtonsoft attributes
grep -rn "using Newtonsoft.Json" --include="*.cs"
grep -rn "\[JsonProperty\|JsonConverter\|JsonIgnore\|JsonConstructor" --include="*.cs"
```

### Step 4: Convert custom JsonConverters

**Newtonsoft converter pattern:**
```csharp
// OLD: Newtonsoft.Json
public class UnixDateTimeConverter : Newtonsoft.Json.JsonConverter<DateTime>
{
public override DateTime ReadJson(JsonReader reader, Type objectType,
DateTime existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var timestamp = (long)reader.Value!;
return DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
}

public override void WriteJson(JsonWriter writer, DateTime value,
JsonSerializer serializer)
{
var timestamp = new DateTimeOffset(value).ToUnixTimeSeconds();
writer.WriteValue(timestamp);
}
}
```

**System.Text.Json converter pattern:**
```csharp
// NEW: System.Text.Json
public class UnixDateTimeConverter : System.Text.Json.Serialization.JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
var timestamp = reader.GetInt64(); // Note: strongly typed reader methods
return DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
}

public override void Write(Utf8JsonWriter writer, DateTime value,
JsonSerializerOptions options)
{
var timestamp = new DateTimeOffset(value).ToUnixTimeSeconds();
writer.WriteNumberValue(timestamp);
}
}
```

**Key differences in converter API:**
- Reader is `ref Utf8JsonReader` (struct, passed by ref) — NOT a class
- Writer is `Utf8JsonWriter` — write methods are `WriteStringValue`, `WriteNumberValue`, `WriteBooleanValue` (typed)
- No `serializer` parameter — use `options` and call `JsonSerializer.Serialize/Deserialize` for nested objects
- For polymorphic deserialization: use `JsonTypeInfo` and `[JsonDerivedType]` (.NET 7+) instead of custom type handling

### Step 5: Replace JToken/JObject/JArray with JsonDocument/JsonElement

| Newtonsoft.Json | System.Text.Json | Notes |
|----------------|-------------------|-------|
| `JToken.Parse(json)` | `JsonDocument.Parse(json)` | **JsonDocument is IDisposable!** Must wrap in `using` |
| `JObject obj = ...` | `JsonElement obj = doc.RootElement` | JsonElement is a struct (no allocation) |
| `obj["key"]` | `obj.GetProperty("key")` | Throws if missing; use `TryGetProperty` for safe access |
| `obj["key"]?.Value<int>()` | `obj.GetProperty("key").GetInt32()` | Type-specific getters |
| `obj.Add("key", value)` | **Not possible** — JsonElement is read-only | Use `JsonNode` (System.Text.Json.Nodes) for mutable DOM |

**For mutable DOM operations, use JsonNode (NOT JsonDocument):**
```csharp
// Mutable DOM — replaces JObject/JArray mutation patterns
var node = JsonNode.Parse(json)!;
node["newProperty"] = "value"; // Add/set properties
node["nested"] = new JsonObject // Create nested objects
{
["key"] = 42
};
var result = node.ToJsonString(); // Serialize back
```

### Step 6: Handle polymorphic serialization

**Newtonsoft.Json (uses $type discriminator):**
```csharp
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto // SECURITY RISK!
};
```

**System.Text.Json (.NET 7+ — type discriminators):**
```csharp
[JsonDerivedType(typeof(CreditCardPayment), typeDiscriminator: "credit")]
[JsonDerivedType(typeof(BankTransferPayment), typeDiscriminator: "bank")]
public abstract class Payment
{
public decimal Amount { get; set; }
}

public class CreditCardPayment : Payment
{
public string CardNumber { get; set; } = "";
}

// Serializes as: {"$type":"credit","amount":99.99,"cardNumber":"..."}
// Note: System.Text.Json uses "$type" by default (configurable)
```

### Step 7: Update package references

```xml
<!-- Remove from .csproj -->
<PackageReference Include="Newtonsoft.Json" Version="*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="*" />

<!-- System.Text.Json is included in the framework — no package needed for .NET 6+ -->
<!-- Only add explicitly if you need a newer version: -->
<!-- <PackageReference Include="System.Text.Json" Version="8.0.0" /> -->
```

**Update using statements:**
```csharp
// Remove:
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Newtonsoft.Json.Converters;

// Add:
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes; // For JsonNode (mutable DOM)
```

## Validation

- [ ] All `using Newtonsoft.Json` references removed
- [ ] All `[JsonProperty]` replaced with `[JsonPropertyName]`
- [ ] Custom converters use `System.Text.Json.Serialization.JsonConverter<T>` base
- [ ] `JObject`/`JToken` replaced with `JsonDocument` (read-only) or `JsonNode` (mutable)
- [ ] API responses match previous JSON format (property casing, null handling)
- [ ] Deserialization handles edge cases: trailing commas, comments, numbers-as-strings
- [ ] No `TypeNameHandling` equivalent (security improvement)
- [ ] `JsonDocument` usages wrapped in `using` statements

## Common Pitfalls

| Pitfall | Solution |
|---------|----------|
| Forgetting `PropertyNameCaseInsensitive = true` | Deserialization silently returns default values for all properties |

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pitfall "Forgetting PropertyNameCaseInsensitive = true" implies this is a required configuration to match Newtonsoft.Json behavior, but this is not accurate. Both libraries are case-sensitive by default.

This pitfall should be reworded to clarify that PropertyNameCaseInsensitive is only needed if the previous Newtonsoft.Json configuration explicitly enabled case-insensitive deserialization, not as a default requirement for all migrations.

Suggested change
| Forgetting `PropertyNameCaseInsensitive = true` | Deserialization silently returns default values for all properties |
| Assuming case-insensitive property matching without configuring it | If your previous Newtonsoft.Json settings enabled case-insensitive property names, set `options.PropertyNameCaseInsensitive = true` in `JsonSerializerOptions`; otherwise both serializers are case-sensitive by default and differing JSON/property casing will deserialize to default values. |

Copilot uses AI. Check for mistakes.
| `JsonDocument` not disposed | Memory leak — always `using var doc = JsonDocument.Parse(...)` |
| Using `JsonElement` after `JsonDocument` is disposed | JsonElement is invalid after dispose; clone with `element.Clone()` if needed |
| `[JsonIgnore]` from wrong namespace | Both Newtonsoft and System.Text.Json have `[JsonIgnore]` — wrong `using` = attribute ignored |
| Custom converter reading past the current token | System.Text.Json reader is strict — must read exactly the right tokens |
| `JsonExtensionData` with `Dictionary<string, object>` | Must be `Dictionary<string, JsonElement>` — not `object` or `JToken` |
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
scenarios:
- name: "Migrate model with Newtonsoft.Json attributes to System.Text.Json"
prompt: |
I'm migrating our ASP.NET Core 8 project from Newtonsoft.Json to System.Text.Json. Here's a model class that uses Newtonsoft attributes and a custom converter. Convert this to System.Text.Json:

```csharp
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;

public class Order
{
[JsonProperty("order_id")]
public int Id { get; set; }

[JsonProperty(Required = Required.Always)]
public string CustomerName { get; set; }

[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string? Notes { get; set; }

[JsonConverter(typeof(StringEnumConverter))]
public OrderStatus Status { get; set; }

[JsonExtensionData]
public Dictionary<string, JToken>? AdditionalData { get; set; }
}
```

Also show me how to configure the JSON options globally to match Newtonsoft.Json's default behavior.

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt asks to "match Newtonsoft.Json's default behavior", but this will lead to incorrect configuration guidance since the skill content incorrectly describes Newtonsoft.Json's default behavior (claiming camelCase and case-insensitivity as defaults).

The prompt should be revised to ask for common migration patterns or specific behavioral compatibility rather than "default behavior".

Copilot uses AI. Check for mistakes.
assertions:
- type: "output_contains"
value: "JsonPropertyName"
- type: "output_matches"
pattern: "(JsonIgnore.*WhenWritingNull|JsonIgnoreCondition)"
- type: "output_matches"
pattern: "(PropertyNameCaseInsensitive|CamelCase|PropertyNamingPolicy)"
- type: "output_matches"
pattern: "(JsonElement|JsonNode)"
rubric:
- "Replaced [JsonProperty(\"order_id\")] with [JsonPropertyName(\"order_id\")]"
- "Replaced NullValueHandling.Ignore with [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]"
- "Replaced [JsonConverter(typeof(StringEnumConverter))] with System.Text.Json equivalent (JsonStringEnumConverter)"
- "Changed [JsonExtensionData] Dictionary value type from JToken to JsonElement (critical difference!)"
- "Configured PropertyNameCaseInsensitive = true to match Newtonsoft default case-insensitive behavior"

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rubric item claims PropertyNameCaseInsensitive should be configured "to match Newtonsoft default case-insensitive behavior", but Newtonsoft.Json is case-sensitive by default, not case-insensitive.

This rubric item propagates the same incorrect information as the skill content and should be corrected or removed.

Suggested change
- "Configured PropertyNameCaseInsensitive = true to match Newtonsoft default case-insensitive behavior"
- "Discussed PropertyNameCaseInsensitive and how it compares to Newtonsoft's default case-sensitive behavior"

Copilot uses AI. Check for mistakes.
- "Mentioned AllowTrailingCommas and/or ReadCommentHandling for compatibility"
- "Warned about behavioral differences (default casing, strict parsing)"

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rubric expects warnings about "default casing" as a behavioral difference, but since both libraries use the same default casing (property names as-is, typically PascalCase), this warning would be misleading.

The rubric should focus on actual behavioral differences such as strict JSON parsing, numbers-as-strings handling, and trailing commas/comments.

Suggested change
- "Warned about behavioral differences (default casing, strict parsing)"
- "Warned about behavioral differences (strict parsing, numbers-as-strings, trailing commas/comments)"

Copilot uses AI. Check for mistakes.
expect_tools: ["bash"]
timeout: 120

- name: "Newtonsoft migration skill should not activate for System.Text.Json usage question"
prompt: "How do I deserialize a JSON string to an object using System.Text.Json in .NET 8?"
assertions:
- type: "output_not_contains"
value: "Newtonsoft"
- type: "output_not_matches"
pattern: "(migrat|JToken|JObject|JsonProperty\\b)"
rubric:
- "Did NOT mention migration from Newtonsoft.Json"
- "Showed standard System.Text.Json deserialization with JsonSerializer.Deserialize"
timeout: 60
Loading