Start Debugging

Fix: IConfiguration.Bind does not populate an array or List<T> property from appsettings.json

The binder silently skips array properties without a public setter, get-only IReadOnlyList<T>, fields and init-only members under the source generator, and it appends to defaults instead of replacing them. Measured on .NET 10.0.12 and 11 RC 1.

Short answer: ConfigurationBinder never throws when it cannot bind a collection. It just leaves the property alone. The usual reasons are: the property is an array (or IReadOnlyList<T>, IEnumerable<T>) with no public setter, it is a public field instead of a property, the section name you passed to GetSection does not match the JSON, or you turned on Native AOT or trimming, which switches the binder to its source generator, and the generator ignores init accessors. Give the property a public get; set;, bind the right section, and turn on ErrorOnUnknownConfiguration so the next mismatch fails loudly. If the list binds but has extra items, that is the other half of this bug: the binder appends to whatever the property already contains, it never replaces it.

Everything below was measured with a file-based probe on SDK 10.0.302 against Microsoft.Extensions.Configuration.Binder 10.0.12, then repeated with 11.0.0-rc.1.26425.128 on the .NET 11 RC 1 SDK. Every row was identical across the two versions. The differences that matter are between the reflection binder and the source-generated binder, not between .NET 10 and 11.

Why the binder silently skips a collection

The reflection binder in ConfigurationBinder.cs decides per property whether it can write to it. The check is short: it needs a public getter, and for anything it has to replace rather than mutate it also needs a public setter (or BinderOptions.BindNonPublicProperties = true). If the check fails, BindProperty returns without a word.

That split between “replace” and “mutate” explains most of the confusing cases:

Element conversion errors are also swallowed. In BindArray and BindCollection, each element is bound inside a try/catch that only rethrows when ErrorOnUnknownConfiguration is set. A value like "abc" in an int[] simply disappears from the result.

The measured matrix

The probe binds { "App": { "Hosts": [ "a.example", "b.example" ] } } into different shapes of options class, once with the default reflection binder and once with EnableConfigurationBindingGenerator=true:

// .NET 10.0.12 / .NET 11 RC 1, Microsoft.Extensions.Configuration.Binder
class GetOnlyArray { public string[] Hosts { get; } = []; }
class GetOnlyList { public List<string> Hosts { get; } = new(); }
class GetOnlyRoList { public IReadOnlyList<string> Hosts { get; } = []; }
class FieldArray { public string[] Hosts = []; }
class PrivateSet { public string[] Hosts { get; private set; } = []; }
class InitOnly { public string[] Hosts { get; init; } = []; }
class Settable { public string[] Hosts { get; set; } = []; }
Property shapeReflection binderSource generator
string[] { get; }[][]
List<string> { get; } = new()[a, b][a, b]
IReadOnlyList<string> { get; } = [][][]
IList<string> { get; } = new List<string>()[a, b][a, b]
string[] public field[][]
string[] { get; private set; }[][]
same, BindNonPublicProperties = true[a, b]NotSupportedException
string[] { get; init; }[a, b][]
string[] { get; set; }[a, b][a, b]
record Opts(string[] Hosts) via Get<T>()[a, b][a, b]
ImmutableArray<string> { get; set; }[]NullReferenceException

Three rows deserve a second look. init accessors work with reflection and are silently skipped by the generator. ImmutableArray<T> is never populated. And the generator build of this probe reported zero warnings, so nothing at compile time tells you about either of those.

Fix it step by step

  1. Confirm the section path. builder.Configuration.GetSection("App") must match the JSON exactly up to the property name (matching is case-insensitive, so casing is not your problem). Binding the root instead of the section, the most common typo, produced [] in the probe. Print what the configuration actually holds before you blame the binder:

    // .NET 10 / 11
    foreach (var kv in builder.Configuration.GetSection("App").AsEnumerable())
        Console.WriteLine($"{kv.Key} = {kv.Value}");
    // Among the output you should see:
    // App:Hosts:0 = a.example
    // App:Hosts:1 = b.example

    Arrays are flattened into indexed keys (App:Hosts:0, App:Hosts:1). If those lines are missing, the problem is the file (not copied to output, wrong environment name, wrong nesting), not the class.

  2. Give the collection a public setter. This is the fix for most reports:

    // .NET 10 / 11
    public sealed class AppOptions
    {
        public string[] Hosts { get; set; } = [];
        public List<EndpointOptions> Endpoints { get; set; } = [];
    }
    
    public sealed class EndpointOptions
    {
        public string Url { get; set; } = "";
    }

    Use get; set;, not init, if there is any chance the project is published with PublishAot or PublishTrimmed (see below). Avoid ImmutableArray<T> in options classes. If you want read-only semantics for consumers, expose IReadOnlyList<T> { get; set; }: the reflection binder assigns a string[] to it and the generator assigns a List<T>, and both populated correctly in the probe.

  3. Make mismatches fail loudly. ErrorOnUnknownConfiguration throws when the configuration has a key with no matching property, and it also stops the binder from swallowing element conversion errors:

    // .NET 10 / 11
    builder.Services.AddOptions<AppOptions>()
        .Bind(builder.Configuration.GetSection("App"),
              o => o.ErrorOnUnknownConfiguration = true)
        .ValidateOnStart();

    With "Host": ["a"] in the JSON (singular) the probe threw InvalidOperationException: 'ErrorOnUnknownConfiguration' was set on the provided BinderOptions, but the following properties were not found on the instance of Settable: 'Host'. With "Ports": [1, "abc", 3] it threw 'ErrorOnUnknownConfiguration' was set and binding has failed, with the inner exception Failed to convert configuration value 'abc' at 'App:Ports:1' to type 'System.Int32'. Without the option, the same binding returned [1, 3].

    Pair it with validation so an empty list is a startup failure instead of a production mystery. Validating options at startup with IValidateOptions<T> covers the ValidateOnStart side in detail.

  4. Stop initializing collections with default values. See the next section: defaults are appended to, not replaced.

The binder appends to defaults instead of replacing them

This is the bug people hit right after fixing the empty list. Give the property a default and bind a section that has values:

// .NET 10 / 11
public sealed class AppOptions
{
    public List<string> Hosts { get; set; } = ["localhost"];
}
// appsettings.json: "App": { "Hosts": [ "a.example", "b.example" ] }
// Result: [ "localhost", "a.example", "b.example" ]

That is what the probe returned for List<T>, string[], IEnumerable<T>, IReadOnlyList<T> and HashSet<T> alike, and for both Bind and Get<T>(). BindArray literally starts by copying the existing elements into a new list before adding the configured ones. Calling Bind twice on the same instance, for example from a change-token callback, produced [a, b, a, b].

This is long-standing, deliberate behaviour. An option to overwrite existing collections was proposed in dotnet/runtime#62112 in 2021 and is still open on the Future milestone, as is dotnet/runtime#118204, so do not wait for a flag. Apply defaults after binding, and only when configuration supplied nothing:

// .NET 10 / 11
public sealed class AppOptions
{
    public string[]? Hosts { get; set; }
}

builder.Services.Configure<AppOptions>(builder.Configuration.GetSection("App"));
builder.Services.PostConfigure<AppOptions>(o => o.Hosts ??= ["localhost"]);

In the probe this gave [a, b] when the section existed and [localhost] when it did not. The IOptionsMonitor<T> factory builds a fresh instance on every reload, so the post-configure step runs against clean state each time. IOptions vs IOptionsSnapshot vs IOptionsMonitor explains when each of those instances is created.

Layered files merge arrays by index

appsettings.Development.json does not replace an array from appsettings.json. Configuration providers only ever contribute keys, and the last provider to set a given key wins. An array is just keys 0, 1, 2. The probe layered these two files:

// appsettings.json
{ "App": { "Hosts": [ "a", "b", "c" ] } }
// appsettings.Development.json
{ "App": { "Hosts": [ "dev1", "dev2" ] } }

The bound result was [dev1, dev2, c]. Index 2 still comes from the base file. The same thing happens with environment variables (App__Hosts__0=env.example replaced only the first element) and command-line arguments (--App:Hosts:2=cli.example appended a third). The ASP.NET Core configuration docs call this out and suggest keeping indexes aligned across sources.

Two things you might try to clear the base array do not work:

What does work is not defining that array in the base file at all, defining the complete array in every environment file, or storing the value as a single delimited string and splitting it in PostConfigure. A plain "Hosts": "a.example,b.example" bound straight into string[] gives [], the binder does not split strings for you.

Native AOT and trimming change the binder under you

The .NET SDK turns the configuration binding source generator on automatically for trimmed apps. From Microsoft.NET.Sdk.FrameworkReferenceResolution.targets in SDK 10.0.302:

<PropertyGroup Condition="'$(PublishTrimmed)' == 'true' Or '$(PublishAot)' == 'true'">
  <EnableRequestDelegateGenerator Condition="'$(EnableRequestDelegateGenerator)' == ''">true</EnableRequestDelegateGenerator>
  <EnableConfigurationBindingGenerator Condition="'$(EnableConfigurationBindingGenerator)' == ''">true</EnableConfigurationBindingGenerator>
</PropertyGroup>

The generator intercepts your Bind, Get<T> and Configure<T> calls at compile time. That is how Native AOT gets binding without reflection, but it is a different implementation, and the probe found four behavioural differences:

CaseReflectionSource generator
string[] { get; init; }bindssilently skipped
BindNonPublicProperties = truebinds private settersNotSupportedException
"Ports": [1, "abc", 3] into int[][1, 3]InvalidOperationException: Failed to convert configuration value 'abc'
"Ports": [1, null, 3] into int[]InvalidCastException[1, 3]
ImmutableArray<string>[]NullReferenceException

So an app that binds happily under dotnet run can bind differently after someone adds <PublishAot>true</PublishAot> to the project. If you are moving to AOT, set <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> explicitly in Debug too, so your tests exercise the same binder as production. Native AOT with ASP.NET Core minimal APIs covers the other generators that flip on at the same time. File-based apps (dotnet run app.cs) default to PublishAot=true, so a quick probe written that way is already running the generator unless you add #:property PublishAot=false.

Other cases worth knowing

How to test your own options class

Keep a unit test that binds your real appsettings.json into your real options type, with the same generator setting as production:

// .NET 10 / 11, xUnit
[Fact]
public void AppOptions_binds_hosts()
{
    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.json")
        .Build();

    var options = config.GetSection("App")
        .Get<AppOptions>(o => o.ErrorOnUnknownConfiguration = true);

    Assert.NotNull(options);
    Assert.Equal(new[] { "a.example", "b.example" }, options.Hosts);
}

For end-to-end coverage including environment-specific files and environment variables, integration tests with WebApplicationFactory let you resolve IOptions<AppOptions> from the real host.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back