Field notes ยท lab reproduction
Null Used to Mean Nothing
For years, some nulls in a production settings file did nothing at all. After the upgrade to .NET 10, they switch off a limit or a timeout, and survive Microsoft's recommended fix.
In our lab, an ASP.NET Core app on .NET 9 rejected a 32 MiB upload with HTTP 413. Kestrel was enforcing its default limit of 30,000,000 bytes per request body. We changed the target framework to .NET 10 and ran it again with the same code and the same appsettings.Production.json. This time, the upload went through: HTTP 200, all 33,554,432 bytes read.
This was the relevant part of the settings file:
"Kestrel": {
"Limits": {
"MaxRequestBodySize": null
}
}
On .NET 9, that null was ignored, so Kestrel kept its limit. On .NET 10, the null reaches MaxRequestBodySize. For Kestrel, null means no limit.
Microsoft documents the change: from .NET 10, configuration binding treats null as a value. To keep the old behavior, Microsoft recommends replacing the null with an empty string. We tried that too. The upload still went through.
We ran 99 scenarios on .NET 9 and .NET 10 with the reflection binder, 95 of them again with the source generator, each twice. The results show what null turns into now, which nulls used to be harmless, and the one change that behaved the same everywhere we tested.
The app had one extra line
Kestrel doesn't read its limits from configuration on its own. In the ASP.NET Core source, its configuration loader reads only endpoints, certificates and endpoint defaults from the Kestrel section. Our app read the limits because we had this line in Program.cs:
builder.Services.Configure<KestrelServerOptions>(
builder.Configuration.GetSection("Kestrel"));
That line binds the whole section to KestrelServerOptions just like any other options class, leaving the binder to decide what the null means. Without it, the null had no effect in either version, and the upload returned 413 in both.
What a null turns into
On .NET 9, the JSON configuration provider turned every null into an empty string. The binder couldn't convert that empty string to a number, a bool, an enum or a TimeSpan, so it threw. For a nullable type, it treated the empty string as no value and left the property alone. On .NET 10, the provider passes the null through, and the binder assigns it.
We bound one property at a time, using a file that set only that property to null, with the reflection binder, which .NET uses whenever the source generator is off. Each property started with a value in code:
| Property type | Value in code | null on .NET 9 | null on .NET 10 |
|---|---|---|---|
int | 30 | exception | 0 |
bool | true | exception | false |
DayOfWeek | Saturday | exception | Sunday |
TimeSpan | 00:00:30 | exception | 00:00:00 |
int? | 3 | 3 | null |
TimeSpan? | 00:05:00 | 00:05:00 | null |
long? | 30,000,000 | 30,000,000 | null |
string | "https://fallback.example" | "" | null |
The exception was an InvalidOperationException: "Failed to convert configuration value at 'Lab:TimeoutSeconds' to type 'System.Int32'."
What this means depends on the property type. For a non-nullable value type, a null used to throw when the options were bound, which stopped the app whenever that happened at startup. Now binding succeeds with 0, false, Sunday or a zero TimeSpan. For a nullable type, a null used to be ignored. It could sit in a settings file for years without anyone noticing. Now it replaces the value in code with null. Kestrel's MaxRequestBodySize is a long?.
Arrays changed less. Null elements became empty strings on .NET 9 and nulls on .NET 10, whether or not the array had items in code. For a property with no value in code, an empty array, [], gave null on .NET 9 and an empty array on .NET 10. Arrays and lists that already had items in code kept them, on both versions, when the setting was null, "" or [].
Reading the key directly can give a third result. GetValue<int>("Lab:TimeoutSeconds", 30) threw on .NET 9 and returned 30 on .NET 10. In the same .NET 10 app, binding that same null to the property gave 0.
A null in the Production file doesn't mean "inherit"
A null in appsettings.Production.json might look like a way to say "use whatever appsettings.json says". It never worked that way. With the reflection binder, we tried it with "TimeoutSeconds": 45 in appsettings.json and "TimeoutSeconds": null in appsettings.Production.json. .NET 9 threw an exception; .NET 10 bound the value to 0.
We also tried an int? initialized to 3 in code and set to 5 in appsettings.json. With null in the Production file, .NET 9 kept 3, the value from code, rather than 5. .NET 10 bound it to null. Of everything we tried, only removing the key from the Production file brought back 45 and 5 on both versions.
Zero is a timeout too
We tested what happens when a timeout is set to null in configuration, with the reflection binder and a loopback endpoint that takes 3 seconds to answer:
| Setting, and the code that uses it | null on .NET 9 | null on .NET 10 |
|---|---|---|
int, 30 in code, passed to CancelAfter | exception reading the options | call cancelled after 1.2 ms |
int, 30 in code, set as HttpClient.Timeout | exception reading the options | ArgumentOutOfRangeException |
int?, 1 in code, CancelAfter only when set | call cancelled after 1.0 s | no timeout, call completed after 3.0 s |
On .NET 9, the app started normally with null configured for the int option. The conversion exception came only when our code first read the options. On .NET 10, the first two rows fail loudly; HttpClient refuses a zero timeout with "value ('00:00:00') must be greater than '00:00:00'." The third is easier to miss: the timeout disappears, so a call that should give up after a second waits as long as the server takes. The upload is just as quiet. With logging at the ASP.NET Core template's levels, the server that accepted the 32 MiB body logged nothing beyond its usual startup and shutdown lines.
The documented fix, and where it stops
Microsoft's breaking-change page recommends replacing null with an empty string to preserve the old behavior. We repeated the binding, validation and upload scenarios with "" in place of null, again with the reflection binder:
| Property type | "" on .NET 9 | "" on .NET 10 |
|---|---|---|
int, bool, DayOfWeek, TimeSpan | exception | exception |
int?, long?, TimeSpan? | value in code | null |
string | "" | "" |
Kestrel MaxRequestBodySize | HTTP 413 | HTTP 200, no limit |
For strings, "" preserves the old result. For non-nullable value types, it restores the exception the old version threw. But it doesn't restore the old behavior for nullable types: .NET 10 binds an empty string to an int? as null, so "" has the same effect as null. That's why the upload still went through.
Of the changes we tried, only deleting the line gave the same result on both versions, with both binders, for every type we tested. When the key is missing, the value set in code or supplied by an earlier settings file stays in place.
What validation catches
Startup validation catches some of these problems. We added [Range] to the timeout and enabled validation on startup:
// CallOptions.cs
using System.ComponentModel.DataAnnotations;
public sealed class CallOptions
{
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
}
// Program.cs, after WebApplication.CreateBuilder(args)
builder.Services.AddOptions<CallOptions>()
.Bind(builder.Configuration.GetSection("Calls"))
.ValidateDataAnnotations()
.ValidateOnStart();
With "TimeoutSeconds": null, the app refused to start on .NET 10: "The field TimeoutSeconds must be between 1 and 300." On .NET 9, it also refused to start, but with the conversion exception. Both results are for the reflection binder; the source generator, below, gives this check nothing to catch.
[Range] accepts null, so it doesn't catch this for nullable properties. On .NET 10, an int? marked [Range(1, 10)] let the app start with null. Adding [Required] to a second int?, MaxRetries, stopped the app: "The MaxRetries field is required."
Kestrel's options have no such attributes, so nothing checks MaxRequestBodySize for you. You have to find that null in the file.
The source generator reads it differently
The configuration binding source generator replaces the reflection binder with code generated at build time. Setting EnableConfigurationBindingGenerator to true turns it on. The .NET 8 release notes say it is enabled by default in AOT-compiled web apps and when PublishTrimmed is set. We built the same lab with the generator enabled and reran every scenario that would compile:
| Property type | null on .NET 9 | null on .NET 10 | "" on .NET 10 |
|---|---|---|---|
int, value in code 30 | 30 | 30 | 30 |
int?, value in code 3 | 3 | null | 3 |
string | "" | null | "" |
On .NET 10, "TimeoutSeconds": null gives 0 with the reflection binder but leaves 30 in place with the generator, so the [Range] check above passed and the app started. The generated code assigns a non-nullable property only when the value is neither null nor empty. Microsoft's breaking-change page, as we read it on November 8, 2026, says the generator already set these properties to their default value, but that isn't what the code generated in our lab did. The page also says the in-memory provider did this before .NET 10. In our .NET 9 test, an in-memory null left the 30 in place. The page may have changed since.
Enabling the generator also stopped our Configure<KestrelServerOptions> line from compiling. The generated code assigns EnableAltSvc, an obsolete Kestrel property, which causes the build to fail with error CS0619. We ran the upload scenarios with the reflection binder only.
Check your own services
- Find the nulls. With GNU grep, run
grep -rnw --include='appsettings*.json' null .. In PowerShell, runGet-ChildItem -Recurse -Filter 'appsettings*.json' | Select-String -Pattern '\bnull\b'. Both commands also match null inside strings and comments, so check each result. Search any other JSON files your app loads, too. - Find the property each null binds to. With the reflection binder on .NET 10, a non-nullable value type gets 0, false or the enum member whose value is 0. A nullable type or string gets null. If a Kestrel limit is documented as "null means unlimited", setting it to null removes that limit.
- Delete any nulls intended to mean "use the default". Replacing them with
""doesn't help a nullable property. - Validate options at startup. Use
[Range]for value types and[Required]for nullable properties that must have a value. - Check whether the project uses the source generator through
EnableConfigurationBindingGenerator,PublishAotorPublishTrimmed. The same null binds differently with the source generator. - Run the upgrade tests with the production settings file as well as the settings file the tests normally use.
What we measured
- Kestrel ignored a null setting on .NET 9. On .NET 10, the same null removed its request body limit, and a 32 MiB upload went from 413 to 200.
- With the reflection binder, nulls on value types threw when bound on .NET 9. On .NET 10, they produced 0, false, Sunday and a zero
TimeSpan. Nulls on nullable types went from "ignored" to null. - When an
int?timeout was read as null, a call previously cancelled at 1.0 s ran to completion at 3.0 s. - Replacing null with
""restored the old behavior for strings and value types, but not for nullable types. Deleting the key gave the same result on every version and binder we tested, leaving the value to code or an earlier settings file. - The source generator preserved the value set in code where the reflection binder assigned 0.
How we ran this
We used .NET 9.0.22 and .NET 10.0.14 (SDK 10.0.403) on Windows 11, with the ASP.NET Core shared framework and no NuGet packages. One project targeted both versions. We also built the same source with the configuration binding source generator enabled.
Each scenario wrote its own appsettings.json and, where needed, appsettings.Production.json. The files were loaded in that order, with the environment set to Production. The upload and timeout scenarios used a real Kestrel server and HttpClient over 127.0.0.1.
Each of the two reflection builds ran all 99 scenarios twice, and each generator build ran 95 of them twice; the other four need the Configure<KestrelServerOptions> line that doesn't compile with the generator. Every outcome matched across both runs, and timings varied by at most 22 ms. The numbers here are from the first run.