Safer JSON evolution with Egil.SystemTextJson.Migration 2
A migration method can be correct while the serializer setup around it is wrong. The source type might be missing from a source-generated serializer context, two historical types might share a discriminator, or two migrators might both claim the same JSON shape. The migration methods alone do not catch those setup mistakes.

In my previous post, I introduced Egil.SystemTextJson.Migration (STJM): declare migrations between C# types, and let deserialization produce the type the application wants to use. The old JSON can stay where it is.
The recent 2.x releases add compiler diagnostics for mistakes in that setup. The analyzers ship inside the main NuGet package, so there is no separate analyzer package to install. They report warnings by default, in the editor and during builds.
Here are three examples, using version 2.2.2 on .NET 10. There are also a few other additions worth mentioning at the end.
A small working migration
Install the package in a .NET 10 project:
dotnet add package Egil.SystemTextJson.Migration --version 2.2.2
The example is the same kind of change as in the original post: a user’s name moves from one property to two. The current type keeps the clean User name, and the historical type becomes UserV1.
using Egil.SystemTextJson.Migration;
[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV1(string Name, int Age);
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record User(string FirstName, string LastName, int Age)
: IMigrateFrom<UserV1, User>
{
public static bool TryMigrateFrom(UserV1 source, out User result)
{
var names = source.Name.Split(' ', 2);
result = new User(names[0], names.Length > 1 ? names[1] : "", source.Age);
return true;
}
}
That deliberately simple name splitting is just for the example. The migration logic itself has not changed: deserialize the old shape, then convert it to the current one.
For source generation, register both types in a context:
using System.Text.Json.Serialization;
[JsonSerializable(typeof(UserV1))]
[JsonSerializable(typeof(User))]
public partial class AppJsonContext : JsonSerializerContext;
Then enable migration support and add the context to the resolver chain:
using System.Text.Json;
using Egil.SystemTextJson.Migration;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
options.TypeInfoResolverChain.Add(AppJsonContext.Default);
var json = """{"$type":"user-v1","name":"Jane Doe","age":30}""";
var user = JsonSerializer.Deserialize<User>(json, options)!;
// User { FirstName = Jane, LastName = Doe, Age = 30 }
Application code still asks for User. It does not need to know which historical shape arrived.
Mistake 1: only registering the current type
It is easy to look at Deserialize<User>() and conclude that the generated context only needs User:
[JsonSerializable(typeof(User))]
public partial class AppJsonContext : JsonSerializerContext;
The library also needs metadata for UserV1 to build User’s migration converter, so this can fail even when reading a current payload. The generator does not discover the source type just because it appears in an IMigrateFrom contract.
The analyzer reports STJM0006:
JsonSerializerContext includes migratable target 'User' but omits required migration source 'UserV1'
The fix is to restore [JsonSerializable(typeof(UserV1))], as in the working example. For larger models, the warning also covers migratable targets reached through wrapper properties and collection elements.
There is a related warning, STJM0007, for missing string metadata. The library injects a string discriminator property, so a context containing only numeric models still needs [JsonSerializable(typeof(string))]. Our User model already supplies string metadata through its name properties.
These checks inspect the declared context. If metadata comes from another resolver at runtime, the analyzer cannot infer that composition. The source generation recipe covers the setup in more detail.
Mistake 2: reusing a source discriminator
Suppose there is another historical user format, with FullName instead of Name. I copy the attribute from UserV1 and forget to change its discriminator:
[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV0(string FullName, int Age);
Add the second contract to User’s interface list, and register UserV0 in AppJsonContext too:
- : IMigrateFrom<UserV1, User>
+ : IMigrateFrom<UserV1, User>, IMigrateFrom<UserV0, User>
Inside User, the additional migration can reuse the existing conversion:
public static bool TryMigrateFrom(UserV0 source, out User result)
=> TryMigrateFrom(new UserV1(source.FullName, source.Age), out result);
Both source types now claim "user-v1" for the same target. When that discriminator arrives, the library cannot tell which source schema to deserialize.
The analyzer reports STJM0001 on the conflicting source discriminator:
Source type 'UserV0' uses discriminator 'user-v1' and property 'configured default' already used for target 'User'
Here, configured default is the discriminator property name supplied by the options, $type by default. The rule checks the pair of property name and value within a target’s migration sources.
Give the added source its own discriminator:
[JsonMigratable(TypeDiscriminator = "user-v0")]
public record UserV0(string FullName, int Age);
That fixes the declarations in this example. For real historical types, the value must match what was written into the stored JSON. Changing an attribute does not change those documents. If two existing schemas really used the same discriminator, assigning a new value to one type will not make the old payloads distinguishable.
Mistake 3: two sources that both read a JSON number
Discriminators work for JSON objects. A plain number such as 42 has no discriminator to inspect.
This target has migrations from both int and long:
[JsonMigratable(TypeDiscriminator = "counter-v2")]
public record Counter(long Value)
: IMigrateFrom<int, Counter>, IMigrateFrom<long, Counter>
{
public static bool TryMigrateFrom(int source, out Counter result)
{
result = new Counter(source);
return true;
}
public static bool TryMigrateFrom(long source, out Counter result)
{
result = new Counter(source);
return true;
}
}
Both sources match the same JSON number shape. There is no schema information in 42 that identifies one of them.
The analyzer reports STJM0009:
Migratable target 'Counter' has multiple source types that deserialize from JSON number values without a discriminator
Here, the two migrations mean the same thing, so keep just the long source:
[JsonMigratable(TypeDiscriminator = "counter-v2")]
public record Counter(long Value) : IMigrateFrom<long, Counter>
{
public static bool TryMigrateFrom(long source, out Counter result)
{
result = new Counter(source);
return true;
}
}
It can read integer values in the long range, including values that previously came from an int. After migration, the structured target writes its own discriminator:
var counterOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
counterOptions.AddJsonMigrationSupport();
var counter = JsonSerializer.Deserialize<Counter>("42", counterOptions)!;
var updatedJson = JsonSerializer.Serialize(counter, counterOptions);
// {"$type":"counter-v2","value":42}
This example uses reflection metadata. A generated context for it would register Counter, long, and string; the earlier AppJsonContext only knows about users.
If the two numeric formats have different meanings, they need something in the payload that distinguishes them. Registering another migrator cannot recover information that the JSON does not contain.
The cost of using STJM
For medium and large reads, the added cost is very small. In the published source-generated benchmarks, reading a current payload adds about 0.16 microseconds, with no additional allocations compared with plain System.Text.Json.
These results come from the benchmarks documented with 2.2.2, run on an i7-13800H with .NET 11 RC.
The table uses BenchmarkDotNet’s ratios: the baseline is 1.00, and 1.09 means the STJM read takes 9% longer. An allocation ratio of 1.00 means both paths allocate the same number of bytes.
| Read scenario | Baseline | STJM ratio | Alloc ratio |
|---|---|---|---|
| No migration, medium | 1.00 | 1.09 | 1.00 |
| No migration, large | 1.00 | 1.01 | 1.00 |
| Migrated during read, medium | 1.00 | 1.11 | 1.00 |
| Migrated during read, large | 1.00 | 1.02 | 1.00 |
For the “No migration” rows, the baseline is plain System.Text.Json reading the current format. The “Migrated during read” rows compare a static STJM migration against plain System.Text.Json plus a hand-written migration. Both migration paths still create the old and current objects; STJM adds no extra allocation in these cases.
The fixed cost is more visible on tiny payloads: the two-property happy-path read has a ratio of 1.53 (251 ns → 384 ns).
On write, STJM delegates to System.Text.Json with an extra discriminator property, such as "$type":"user-v2". No migration runs when writing. The extra work is essentially asking the serializer to write that property along with the rest of the object.
There were two performance improvements in v2:
- Preserving generated serialization. Registering migration through
TypeInfoResolverChainlets unrelated types keep their generated serializers when the other fast-path requirements are met. Migratable types and types containing them still use metadata serialization. In the before/after measurements, source-generated serialization overhead fell from about 71% to 37% for medium payloads, and from 32% to 6% for large ones. - Typed migration calls. Source and target value types no longer need to be boxed to call a migrator. The measured struct migration cases allocated 48 bytes less per operation and were about 3–7% faster in the pinned .NET 10 comparison.
For an application reading documents from a database or blob storage, I would usually look elsewhere before optimizing that extra fraction of a microsecond. If JSON processing is the main workload, the full source-generation and reflection reports are a better starting point than one overall overhead number.
What else changed in 2.x?
There are two other changes worth calling out:
- C# unions on .NET 11 (preview). A
UserResult(User, UserNotFound)union can routeuser-v1JSON to the currentUsercase and run its migration, without includingUserV1as a case. The existing[JsonPolymorphic]/[JsonDerivedType]limitation still applies. See the union examples. - Broader matching for old payloads. Additional sources include
Guid,DateOnly, andbyte[], plus quoted numbers when number handling allows them and collection matching by the first element’s discriminator or shape. Array, primitive, and dictionary sources already existed in 1.7.4. See the 2.0 changes.
When upgrading, keep the migration resolver in the chain: add contexts as above, or make sure a replacement resolver delegates to it. The upgrade guide describes the changed setup rules.
Still test the old JSON
The diagnostics catch mistakes that are visible in the C# declarations. They cannot prove that a name-splitting rule is correct, that every historical payload is still supported, or that runtime registration matches those declarations.
I would still keep a few real old payloads as fixtures and deserialize them as the current type. That exercises both the migration and the serializer setup. Testing only JSON written by the current version misses the reason for having the migrations in the first place.
If warnings should stop a build in your project, use TreatWarningsAsErrors or set the individual diagnostic severities in .editorconfig. The analyzer warnings do not fail a build by themselves. I wrote about my warnings-as-errors setup earlier this year.
Links
- Egil.SystemTextJson.Migration on GitHub
- Version 2.2.2 on NuGet
- Compiler diagnostics and runtime errors
- The original introduction
Hope this helps.
Comments