Start Debugging

Fix: [FromForm] Dictionary<string, string> is always null in a minimal API

A [FromForm] Dictionary in a minimal API binds with an empty prefix: the form keys must be [key], not metadata[key]. Wrap it in a class to keep readable names.

A [FromForm] Dictionary<string, string> parameter in a minimal API does not use the parameter name as a form key prefix. The form mapper starts at the root of the form, so it looks for [author] and [env], not metadata[author] or metadata.author. Send bracket keys with no prefix, or, better, wrap the dictionary in a class and post Metadata[author] so the wire format stays readable. Nothing is logged and no 400 is returned when the keys do not match: the parameter simply arrives as null.

Everything below was measured on ASP.NET Core 10.0.5 with SDK 10.0.201. The relevant binding code is identical on the release/11.0 branch, so the behaviour carries into .NET 11.

The error in context

There is no exception to search for, which is exactly why this one burns an afternoon. The handler runs, the file binds, and the dictionary is null:

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/broken", ([FromForm] Dictionary<string, string> metadata, IFormFile file) =>
    Results.Text($"metadata={(metadata is null ? "null" : JsonSerializer.Serialize(metadata))}, file={file?.FileName}"))
   .DisableAntiforgery();
curl -X POST http://localhost:5222/broken \
  -F "metadata[author]=marius" -F "metadata[env]=prod" -F "file=@a.txt"
metadata=null, file=a.txt

The same null comes back for metadata.author=marius, for a bare author=marius, and for a request that omits the keys entirely. The status code is 200 every time.

You only see an exception once the keys are close enough that the mapper starts reading them. With a Dictionary<string, int> and a value that does not parse:

Microsoft.AspNetCore.Http.BadHttpRequestException: The value 'notanint' is not valid for 'b'.
 ---> Microsoft.AspNetCore.Components.Endpoints.FormMapping.FormDataMappingException
   at Microsoft.AspNetCore.Components.Endpoints.FormMapping.DictionaryConverter`5.TryRead(...)

That stack trace is the tell. The type doing the work lives in Microsoft.AspNetCore.Components.Endpoints.FormMapping, the same form-mapping layer Blazor uses, and its key conventions are not the ones MVC taught you.

Why this happens

Minimal API form binding has two completely separate code paths, and which one a parameter takes is decided by a single predicate in RequestDelegateFactory:

// dotnet/aspnetcore, src/Http/Http.Extensions/src/RequestDelegateFactory.cs, release/10.0
var useSimpleBinding = parameter.ParameterType == typeof(string) ||
    parameter.ParameterType == typeof(StringValues) ||
    parameter.ParameterType == typeof(StringValues?) ||
    ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType) ||
    (parameter.ParameterType.IsArray && ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType.GetElementType()!));
hasTryParse = useSimpleBinding;
return useSimpleBinding
    ? BindParameterFromFormItem(parameter, formAttribute.Name ?? parameter.Name, factoryContext)
    : BindComplexParameterFromFormItem(parameter, string.IsNullOrEmpty(formAttribute.Name) ? parameter.Name : formAttribute.Name, factoryContext);

Simple binding reads HttpContext.Request.Form[key] where key is the parameter name. That is the behaviour everyone expects, and it is what you get for string, int, Guid, DateOnly, and anything else with a TryParse.

Dictionary<string, string> has no TryParse, so it falls into BindComplexParameterFromFormItem, which hands the whole form to the shared form mapper:

// FormDataMapper.Map<Dictionary<string, string>>(name_reader, FormDataMapperOptions);
var invokeMapMethodExpr = Expression.Call(
    FormDataMapperMapMethod.MakeGenericMethod(parameter.ParameterType),
    formReader,
    Expression.Constant(formDataMapperOptions));

Look at the arguments: the reader and the options. There is no prefix. The key computed on the line above is only used as a dictionary key in factoryContext.TrackedParameters, never pushed onto the reader’s prefix stack. The mapper therefore reads the dictionary from the root of the form, and a root-level dictionary entry is spelled [author].

That is the entire bug: the parameter is named metadata, but the form mapper was never told the name.

This is also why the behaviour feels like a regression when you move an endpoint off controllers. MVC’s model binder tries the parameter name as a prefix and then falls back to the empty prefix, so a controller action accepts both spellings:

// .NET 10.0.201, controller action, both curl shapes below return the same result
[HttpPost("dict")]
public IActionResult Dict([FromForm] Dictionary<string, string> metadata, IFormFile file)
    => Content($"count={metadata?.Count}");
curl -F "metadata[author]=marius" -F "file=@a.txt"   ->  count=1
curl -F "[author]=marius"         -F "file=@a.txt"   ->  count=1

Minimal APIs accept only the second. If you are weighing the two hosting models more broadly, minimal APIs vs controllers in ASP.NET Core 11 covers the other places their binding semantics diverge.

Minimal repro

A complete app, plus the request shapes that do and do not work:

// .NET 10.0.201, ASP.NET Core 10.0.5
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAntiforgery();

app.MapPost("/dict", ([FromForm] Dictionary<string, string> metadata, IFormFile file) =>
    Results.Text($"metadata={(metadata is null ? "null" : JsonSerializer.Serialize(metadata))}, file={file?.FileName}"))
   .DisableAntiforgery();

app.MapPost("/list", ([FromForm] List<string> tags, IFormFile file) =>
    Results.Text($"tags={(tags is null ? "null" : JsonSerializer.Serialize(tags))}"))
   .DisableAntiforgery();

app.Run();

Measured results against that app:

RequestResult
-F "metadata[author]=marius"metadata=null
-F "metadata.author=marius"metadata=null
-F "author=marius"metadata=null
-F "[author]=marius" -F "[env]=prod"metadata={"author":"marius","env":"prod"}
-F "tags=a" -F "tags=b"tags=null
-F "tags[0]=a" -F "tags[1]=b"tags=null
-F "[0]=a" -F "[1]=b"tags=["a","b"]

The pattern is consistent: a top-level [FromForm] collection parameter is addressed with an empty prefix, so dictionaries use [key] and lists use [0], [1], and so on. The parameter name is dead weight.

Fix, in detail

Four options, in the order I would reach for them.

1. Wrap the dictionary in a class

This is the fix worth shipping. A property on a class does get a prefix, because the mapper pushes the property name onto its prefix stack as it descends, so the wire format goes back to something a human can read and a client library can generate.

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] UploadRequest request, IFormFile file) =>
    Results.Text($"request={JsonSerializer.Serialize(request)}, file={file?.FileName}"))
   .DisableAntiforgery();

public class UploadRequest
{
    public Dictionary<string, string> Metadata { get; set; } = new();
}
curl -X POST http://localhost:5222/upload \
  -F "Metadata[author]=marius" -F "Metadata[env]=prod" -F "file=@a.txt"
request={"Metadata":{"author":"marius","env":"prod"}}, file=a.txt

Key matching is case-insensitive, so metadata[author] binds to the Metadata property too. The nested dictionary can also sit deeper: Meta.Tags[a]=1 binds fine if Meta is itself a property.

You can pull the file into the same class, which keeps the endpoint signature to a single parameter:

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] UploadWithFile request) =>
    Results.Text($"metadata={JsonSerializer.Serialize(request.Metadata)}, file={request.File?.FileName}"))
   .DisableAntiforgery();

public class UploadWithFile
{
    public Dictionary<string, string> Metadata { get; set; } = new();
    public IFormFile? File { get; set; }
}

Posting -F "Metadata[author]=marius" -F "File=@a.txt" binds both. The file property is matched by property name, the same rule that applies to a top-level IFormFile parameter.

2. Keep the dictionary parameter and fix the client

If the client is yours and the endpoint signature is fixed, just send root-level bracket keys:

curl -X POST http://localhost:5222/dict \
  -F "[author]=marius" -F "[env]=prod" -F "file=@a.txt"

It works, and it is one character of change per key. It is also the shape nobody will guess when they read the handler six months from now, and it does not survive a second dictionary parameter (see the gotchas). Treat it as a stopgap.

3. Read the form yourself

The most explicit option, and the only one that survives the Request Delegate Generator. IFormCollection is bound as a whole-form parameter with no mapping layer involved, so you own the key convention:

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", (IFormCollection form) =>
{
    var metadata = form
        .Where(kv => kv.Key.StartsWith("metadata[", StringComparison.Ordinal) && kv.Key.EndsWith(']'))
        .ToDictionary(kv => kv.Key[9..^1], kv => kv.Value.ToString());

    return Results.Text($"metadata={JsonSerializer.Serialize(metadata)}, files={form.Files.Count}");
}).DisableAntiforgery();
metadata={"author":"marius","env":"prod"}, files=1

Verbose, but it accepts metadata[author] directly and gives you a real error path when a key is malformed instead of a silent null.

4. Send the metadata as one JSON field

If the metadata is genuinely open-ended, stop modelling it as form keys. One form field holding a JSON document binds through the simple path, because string short-circuits the predicate above:

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] string metadata, IFormFile file) =>
{
    var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(metadata);
    return Results.Text($"metadata={JsonSerializer.Serialize(parsed)}, file={file?.FileName}");
}).DisableAntiforgery();
curl -X POST http://localhost:5222/upload \
  -F 'metadata={"author":"marius","env":"prod"}' -F "file=@a.txt"

This is the only option that gives you nested values, arrays, and non-string types without fighting the key syntax, and it works identically under AOT.

Gotchas and variants

The rule to remember is short: in a minimal API, a [FromForm] parameter is addressed by name only if its type can be parsed from a single string. Everything else goes through the Blazor form mapper, which starts at the root of the form and does not know what your parameter is called. Give it a class to descend into and the names come back.

Sources

Comments

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

< Back