Pick the reader first, because the attributes follow from it
Four different libraries end up reading YAML into C# objects, and they disagree on how a key finds its property. System.Text.Json compares names exactly, so port does not reach Port without help. Newtonsoft ignores case on its own. YamlDotNet compares exactly unless you hand the deserializer a naming convention. The configuration binder behind IOptions<T> ignores case but has no idea what to do with a hyphen.
The Read by control above changes the attribute written on each property to match, and skips the attribute wherever the library would find the key by itself.
| Read by | Attribute written | Matches on its own | Where the YAML comes from |
|---|---|---|---|
| System.Text.Json | [JsonPropertyName("base-url")] on every property whose key is not already the property name | Exact name only | YAML converted to JSON first, or a YAML file loaded through a converter into JsonSerializer |
| Newtonsoft | [JsonProperty("base-url")] only where the key differs by more than case | Case-insensitive | Same as above, in projects still on Json.NET |
| YamlDotNet | [YamlMember(Alias = "base-url")] on every property whose key is not already the property name | Exact name, or a naming convention set on the deserializer | YAML read directly with DeserializerBuilder |
| IConfiguration | [ConfigurationKeyName("base-url")] only where the key differs by more than case | Case-insensitive | A YAML configuration provider feeding builder.Configuration, bound with Get<T>() or the options pattern |
| No attributes | None | Depends on the caller | A class you will annotate by hand, or a file whose keys already match C# names |
When every key in a file follows one convention, the attributes are not the best answer. The log says so: a file that is kebab-case throughout gets a note pointing at JsonNamingPolicy.KebabCaseLower for System.Text.Json on .NET 8 or later, HyphenatedNamingConvention for YamlDotNet, or KebabCaseNamingStrategy for Newtonsoft. One line on the serializer replaces every attribute in the class, and the class stops caring how the file spells its keys. The first sample mixes listen_port in with hyphenated keys on purpose, so that note stays quiet and the attributes stay.
How a YAML number picks int, long, double or decimal
YAML has one number type. C# has around a dozen. The generator reads the literal, not the intent, so a whole number inside plus or minus 2,147,483,647 is int and anything past that is long. A value with a decimal point or an exponent is double, or decimal when the first checkbox is on. Turn that on for money and rates. 0.0025 as a double is not 0.0025, and a per-call price that drifts in the fourth decimal place is the kind of bug that appears on an invoice months later.
listen_port: 8080
max-body-bytes: 4294967296
price-per-call: 0.0025
api-version: 1.10
release-date: 2026-09-14public int ListenPort { get; set; }
public long MaxBodyBytes { get; set; }
public decimal PricePerCall { get; set; }
public decimal ApiVersion { get; set; }
public DateTime ReleaseDate { get; set; }The fourth line is the one to look at. api-version: 1.10 is unquoted, so every YAML parser reads it as the number 1.1, and the property comes out numeric. The log flags the line. The fix is in the file, not the class: write "1.10" with quotes and the property becomes string, which is what a version usually needs to be. The same trap catches build: 010, which is octal in YAML 1.1 and decimal in 1.2 depending on the parser, so quote that one too.
Dates get the same treatment in the other direction. An unquoted 2026-09-14 is recognised as a date while the file is read, but the property stays string unless the Dates as DateTime box is ticked, because nothing on the .NET side agrees on what a bare date becomes. With the box on, a date or a timestamp without an offset is DateTime, and a timestamp that carries Z or +05:00 is DateTimeOffset, which keeps the offset instead of silently shifting the value to the machine's local zone.
Nullable comes from evidence, not from a checkbox
A property is written as bool? or string? in three cases only. The key is missing from some entries in a list of maps, like readonly on one replica out of two in the first sample. The value is ~ or null somewhere in the file. Or the same key holds a string in one place and a number in another, which types as object? with a warning, since no single C# type covers both. Every other property is non-nullable, and the log explains each exception with the count it came from, 1 of 2 entries or 3 of 4, so you decide whether the sample was representative or the field is optional for real.
Non-nullable has a cost under #nullable enable, which the output turns on at the top of the file. A plain public string Name { get; set; } earns warning CS8618 from the compiler, because nothing guarantees the property holds a value before something reads it. The class and record shapes answer that with initializers, = ""; on strings and = new(); on lists, dictionaries and nested classes.
The required members shape answers it differently: no initializers, and the required keyword on every non-nullable property instead. That needs C# 11, and only System.Text.Json on .NET 7 or later treats the keyword as a deserialization rule, throwing when a required key is absent. Newtonsoft sees the keyword at compile time only and leaves the property at its default when the key is missing, so pair the shape with [JsonProperty(Required = Required.Always)] there if you want the same guarantee.
What the configuration binder will and will not do
Most YAML that reaches a .NET service is settings, and settings usually arrive through IConfiguration rather than a serializer. There is no YAML provider in the box, so the file is loaded with a package such as NetEscapades.Configuration.Yaml, after which builder.Configuration.GetSection("service").Get<Service>() or services.Configure<Service>(section) binds the section onto the class. The binder matches property names to keys ignoring case and nothing else, so base-url never lands on BaseUrl by itself. Since .NET 6 the fix is [ConfigurationKeyName("base-url")] from Microsoft.Extensions.Configuration, and the IConfiguration option writes exactly those, on exactly the properties that need one.
Two things the binder does that the class cannot express. Any scalar is bound through TypeConverter, so "true" in quotes and bare true both reach a bool, which is why the second sample quotes its environment values without changing the class. And a list is bound from keys named 0, 1, 2, which is how a YAML sequence gets flattened into configuration keys, so List<string> works but a string[] would too.
Where this reader stops
- Anchors are dropped, aliases and merge keys are not resolved. A
&defaultsanchor reads in place, a*defaultsalias becomes null, and a<<: *defaultsmerge line is skipped with a warning. Load the third sample to see all three. A file built around them types more accurately after a pass through the YAML to JSON converter, which expands them, followed by JSON to C#. - One document. A second
---ends the read, and the log names the line. Split the file if the second document is the one you want. - yes, no, on and off are strings. YAML 1.1 read them as booleans and YAML 1.2 does not. System.Text.Json and the configuration binder both refuse
yesfor abool, so a string is the type that will not throw at runtime. Change the file totrueif a bool is what you meant. - Block scalars are one string. A
|or>value is folded into a single string property. The lines inside are not inspected for structure. - Flow collections are read shallowly.
[main, release/*]and{a: 1, b: 2}work. A flow map nested inside a flow list inside a flow map is more than the reader follows, and the log will say a line was skipped. - Enum-shaped strings stay string. A key like
ssl-modemight only ever holdrequireordisable, but one file cannot prove the full set, and a guessed enum breaks the moment a third value appears. - Nothing is uploaded. Parsing and generation run in this tab after the page loads. A file holding hostnames, ports and internal URLs stays on your machine.
A JSON response from an API fits the JSON to C# converter better, since that page infers from response shapes rather than config files. XML settings go through XML to C#, which understands attributes and repeated elements. And a YAML file that will not read here at all deserves a pass through the YAML validator first, which lists every problem in the file rather than stopping at the first one.
