The YAML Norway problem is what happens when a parser reads country: NO as the boolean false instead of the country code for Norway. It comes from YAML 1.1, which treats yes, no, on, off, y and n as booleans and a leading zero as octal. YAML 1.2 fixed this in 2009: its Core schema has only true and false as booleans. But many libraries still read some 1.1 forms by default, so the fix in your code depends on which library you use. Below, the same three lines are read by nine widely used Go, Rust and C# libraries and by oneyaml, followed by the rules that keep a file safe whichever reader ends up with it.
Why NO becomes false
YAML guesses the type of every unquoted (plain) scalar. Which guesses it makes depends on the schema. The YAML 1.1 boolean type resolves this pattern to a boolean:
y|Y|yes|Yes|YES|n|N|no|No|NO |true|True|TRUE|false|False|FALSE |on|On|ON|off|Off|OFF
So NO, n and off are all false. The YAML 1.1 integer type adds more surprises: a leading 0 means octal, _ is ignored inside numbers, and : means base 60, so 22:30 is the integer 1350.
The YAML 1.2.2 Core schema resolves far fewer forms. A boolean is true, True, TRUE, false, False or FALSE. An integer is [-+]? [0-9]+ in base 10, 0o octal or 0x hex. Anything that matches no rule is a string.
YAML boolean values in 1.1 and 1.2
| Plain scalar | YAML 1.1 | YAML 1.2 Core schema |
|---|---|---|
NO, no, n, off | boolean false | string |
yes, y, on | boolean true | string |
true, False | boolean | boolean |
0755 | octal, the integer 493 | the integer 755 |
0o755 | string | octal, the integer 493 |
1_000 | the integer 1000 | string |
22:30 | base 60, the integer 1350 | string |
One correction to a common claim: under YAML 1.2, 0755 is not a string. It matches the base-10 integer rule, so it is the number 755. The only way to get the text 0755 in every version is to quote it.
What Go YAML libraries do with it
This program reads country: no, enabled: yes and mode: 0755 into a map[string]any with four Go libraries:
package main import ( "fmt" goccy "github.com/goccy/go-yaml" yamlv3 "go.yaml.in/yaml/v3" oneyaml "oneyaml.dev/go" k8syaml "sigs.k8s.io/yaml" ) const src = "country: no\nenabled: yes\nmode: 0755\n" func main() { decoders := []struct { name string unmarshal func([]byte, any) error }{ {"go.yaml.in/yaml/v3", yamlv3.Unmarshal}, {"sigs.k8s.io/yaml", func(b []byte, v any) error { return k8syaml.Unmarshal(b, v) }}, {"goccy/go-yaml", goccy.Unmarshal}, {"oneyaml.dev/go", func(b []byte, v any) error { return oneyaml.Unmarshal(b, v) }}, } for _, d := range decoders { var m map[string]any if err := d.unmarshal([]byte(src), &m); err != nil { fmt.Printf("%-20s error: %v\n", d.name, err) continue } fmt.Printf("%-20s country=%#v enabled=%#v mode=%v\n", d.name, m["country"], m["enabled"], m["mode"]) } }
go.yaml.in/yaml/v3 country="no" enabled="yes" mode=493 sigs.k8s.io/yaml country=false enabled=true mode=493 goccy/go-yaml country="no" enabled="yes" mode=493 oneyaml.dev/go country="no" enabled="yes" mode=755
Untyped, only sigs.k8s.io/yaml has the Norway problem, because it converts YAML to JSON with YAML 1.1 rules before encoding/json sees it. Typed fields tell a different story:
package main import ( "fmt" "strings" goccy "github.com/goccy/go-yaml" yamlv3 "go.yaml.in/yaml/v3" oneyaml "oneyaml.dev/go" k8syaml "sigs.k8s.io/yaml" ) type Settings struct { Country string `yaml:"country" json:"country"` Enabled bool `yaml:"enabled" json:"enabled"` Mode int `yaml:"mode" json:"mode"` } func main() { decoders := []struct { name string unmarshal func([]byte, any) error }{ {"go.yaml.in/yaml/v3", yamlv3.Unmarshal}, {"sigs.k8s.io/yaml", func(b []byte, v any) error { return k8syaml.Unmarshal(b, v) }}, {"goccy/go-yaml", goccy.Unmarshal}, {"oneyaml.dev/go", func(b []byte, v any) error { return oneyaml.Unmarshal(b, v) }}, } for _, d := range decoders { var cells []string for _, src := range []string{"country: no", "enabled: yes", "mode: 0755"} { var s Settings if err := d.unmarshal([]byte(src), &s); err != nil { cells = append(cells, "error") continue } switch { case strings.HasPrefix(src, "country"): cells = append(cells, fmt.Sprintf("%q", s.Country)) case strings.HasPrefix(src, "enabled"): cells = append(cells, fmt.Sprint(s.Enabled)) default: cells = append(cells, fmt.Sprint(s.Mode)) } } fmt.Printf("%-19s Country=%-8s Enabled=%-6s Mode=%s\n", d.name, cells[0], cells[1], cells[2]) } }
go.yaml.in/yaml/v3 Country="no" Enabled=true Mode=493 sigs.k8s.io/yaml Country="false" Enabled=true Mode=493 goccy/go-yaml Country="no" Enabled=error Mode=493 oneyaml.dev/go Country="no" Enabled=error Mode=755
- sigs.k8s.io/yaml v1.6.0 stores the text
"false"in astringfield: the country code is lost even with a typed struct. - go.yaml.in/yaml/v3 v3.0.5 reads
yesastrueinto aboolfield, as its README says (YAML 1.1 booleans “are supported as long as they are being decoded into a typed bool value”), and0755as octal 493. - goccy/go-yaml v1.19.2 refuses
yesfor aboolbut reads0755as 493. - oneyaml.dev/go v1.0.0 follows the Core schema in both cases:
yesinto aboolis an error with its line and column (line 1, column 10: /enabled: cannot unmarshal !!str `yes` into bool), and0755is 755.
Rust: the serde_yaml family and serde-saphyr
The same three lines, read into typed fields:
use serde::Deserialize; #[derive(Deserialize)] struct Settings { country: Option<String>, enabled: Option<bool>, mode: Option<i64>, } fn row(name: &str, read: fn(&str) -> Result<Settings, String>) { let cell = |src: &str| match read(src) { Ok(s) => s.country.map(|c| format!("{c:?}")) .or(s.enabled.map(|b| b.to_string())) .or(s.mode.map(|m| m.to_string())) .unwrap_or_default(), Err(_) => "error".to_string(), }; println!("{name:<22} country={:<8} enabled={:<6} mode={}", cell("country: no"), cell("enabled: yes"), cell("mode: 0755")); } fn main() { row("serde_yaml_ng 0.10.0", |s| serde_yaml_ng::from_str(s).map_err(|e| e.to_string())); row("serde_norway 0.9.42", |s| serde_norway::from_str(s).map_err(|e| e.to_string())); row("yaml_serde 0.10.7", |s| yaml_serde::from_str(s).map_err(|e| e.to_string())); row("serde-saphyr 2.0.0", |s| serde_saphyr::from_str(s).map_err(|e| e.to_string())); row("oneyaml 1.2.0", |s| oneyaml::from_str(s).map_err(|e| e.to_string())); }
serde_yaml_ng 0.10.0 country="no" enabled=error mode=error serde_norway 0.9.42 country="no" enabled=error mode=error yaml_serde 0.10.7 country="no" enabled=error mode=error serde-saphyr 2.0.0 country="no" enabled=true mode=error oneyaml 1.2.0 country="no" enabled=error mode=755
The serde_yaml forks (serde_yaml_ng, serde_norway and yaml_serde) don’t read yes as a boolean. They read 0755 as the string "0755", so an integer field refuses it. serde-saphyr makes the Rust type the schema: by default it accepts YAML 1.1 forms such as no and off when the target is a bool, and its strict_booleans option turns that off (README). oneyaml reads the Core schema, so 0755 is 755 and yes into a bool is an error whose message names the cause: YAML 1.2 reads `yes` as a string (its booleans are `true` and `false`).
C#: YamlDotNet, SharpYaml and OneYaml
using System.Text.Json; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; var ydn = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); var sharpOptions = new SharpYaml.YamlSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var readers = new (string Name, Func<string, Settings> Typed, Func<string, object?> Untyped)[] { ("YamlDotNet 18.1.0", ydn.Deserialize<Settings>, yaml => ydn.Deserialize<object>(yaml)), ("SharpYaml 3.16.0", yaml => SharpYaml.YamlSerializer.Deserialize<Settings>(yaml, sharpOptions)!, yaml => SharpYaml.YamlSerializer.Deserialize<object>(yaml)), ("OneYaml 1.2.1", yaml => OneYaml.YamlSerializer.Deserialize<Settings>(yaml)!, yaml => OneYaml.YamlSerializer.Deserialize<object>(yaml)), }; foreach (var (name, typed, untyped) in readers) { Console.WriteLine($"{name,-18} typed: Country={Show(() => typed("country: no").Country),-7} " + $"Enabled={Show(() => typed("enabled: yes").Enabled),-6} Mode={Show(() => typed("mode: 0755").Mode)}"); Console.WriteLine($"{"",-18} untyped: country={Show(() => Value(untyped("country: no"), "country")),-7} " + $"enabled={Show(() => Value(untyped("enabled: yes"), "enabled")),-6} mode={Show(() => Value(untyped("mode: 0755"), "mode"))}"); } static object? Value(object? root, string key) => root switch { IDictionary<object, object?> d => d[key], IDictionary<string, object?> d => d[key], _ => throw new InvalidOperationException(root?.GetType().Name), }; static string Show<T>(Func<T> get) { try { object? v = get(); return v switch { string s => $"\"{s}\"", bool b => b ? "true" : "false", _ => $"{v}" }; } catch (Exception) { return "error"; } } class Settings { public string? Country { get; set; } public bool Enabled { get; set; } public int Mode { get; set; } }
YamlDotNet 18.1.0 typed: Country="no" Enabled=true Mode=493 untyped: country="no" enabled="yes" mode="0755" SharpYaml 3.16.0 typed: Country="no" Enabled=error Mode=755 untyped: country="no" enabled="yes" mode=755 OneYaml 1.2.1 typed: Country="no" Enabled=error Mode=755 untyped: country="no" enabled="yes" mode=755
YamlDotNet 18.1.0 reads yes as true and 0755 as 493 into typed members. Its README lists YAML 1.2 support for the parser and only 1.1 for the emitter. Untyped, it returns every scalar as a string. SharpYaml 3.16.0 and OneYaml 1.2.1 read the YAML 1.2 Core schema both ways.
How to avoid the Norway problem
Quote strings that look like something else
This is the only fix that works in every reader. Quote anything that is an identifier, even when it looks like a number or a word: country and language codes, versions, ZIP codes, file modes meant as text, SHAs, phone numbers. country: "NO" and mode: "0755" mean the same thing under YAML 1.1 and 1.2.
Read with a YAML 1.2 Core schema parser
A typed struct alone doesn’t protect you, as the sigs.k8s.io/yaml and yaml.v3 rows show. Pick a library that reads the Core schema by default, and you get either the right value or an error that points at the line.
Write YAML that every reader agrees on
Files you write today will be read by tools on YAML 1.1, so the writer should quote the strings a 1.1 reader would take for something else. oneyaml does this when it serializes, and a Document edit does it when you ask for it with YAML11Strings():
package main import ( "fmt" "log" oneyaml "oneyaml.dev/go" ) type Site struct { Country string `yaml:"country"` Mode string `yaml:"mode"` Debug string `yaml:"debug"` } func main() { out, err := oneyaml.Marshal(Site{Country: "no", Mode: "0755", Debug: "on"}) if err != nil { log.Fatal(err) } fmt.Print(string(out)) src := []byte("country: se # ISO 3166 code\n") for _, opts := range [][]oneyaml.Option{nil, {oneyaml.YAML11Strings()}} { doc, err := oneyaml.Parse(src, opts...) if err != nil { log.Fatal(err) } if err := doc.Set("/country", "no"); err != nil { log.Fatal(err) } fmt.Print(doc) } }
country: 'no' mode: '0755' debug: 'on' country: no # ISO 3166 code country: 'no' # ISO 3166 code
By default an edit judges plainness by the Core schema, so no is written plain, which oneyaml reads back as the string you set. With YAML11Strings(), it is quoted, which is what you want when Kubernetes tooling built on sigs.k8s.io/yaml or yaml.v2 also reads the file (editing). The Rust to_string and C# YamlSerializer.Serialize quote such strings too: C# writes 'yes' and '007' so that “the output reads back as the same values under YAML 1.2 and YAML 1.1” (serializer).
Read old YAML 1.1 files on purpose
Sometimes a file was written for a 1.1 reader and enabled: yes really means true. Say so in code rather than changing what every file means. In Rust, use the Compat::yaml_1_1() preset, or let a file that starts with %YAML 1.1 choose for itself:
use oneyaml::{Compat, Settings}; use serde::Deserialize; #[derive(Deserialize)] struct Legacy { enabled: bool, mode: u32, } fn main() { let src = "enabled: yes\nmode: 0755\n"; // YAML 1.2 by default: `yes` is a string, so a bool field refuses it. if let Err(err) = oneyaml::from_str::<Legacy>(src) { println!("default: {err}"); } // Read a legacy YAML 1.1 file on purpose. let legacy: Legacy = oneyaml::from_str_with(src, &Compat::yaml_1_1()).unwrap(); println!("yaml_1_1: enabled={} mode={}", legacy.enabled, legacy.mode); // Or let each file choose: `%YAML 1.1` switches that document to 1.1 rules. let declared = "%YAML 1.1\n---\nenabled: yes\nmode: 0755\n"; let settings = Settings::new().honor_version_directive(true); let legacy: Legacy = oneyaml::from_str_with(declared, &settings).unwrap(); println!("%YAML 1.1: enabled={} mode={}", legacy.enabled, legacy.mode); }
default: invalid type: string "yes", expected a boolean; YAML 1.2 reads `yes` as a string (its booleans are `true` and `false`) at enabled (1:10) yaml_1_1: enabled=true mode=493 %YAML 1.1: enabled=true mode=493
In Go, the compat/yamlv3 and compat/k8syaml packages read the YAML 1.1 forms that yaml.v3 and sigs.k8s.io/yaml read (migrating from yaml.v3). In C#, the OneYaml.Compat.YamlDotNet and OneYaml.Compat.SharpYaml packages do the same for code moving from those libraries (YamlDotNet compatibility). The Rust cookbook has more on YAML 1.1 booleans.
Where to go next
Choosing a library for one language: Go YAML libraries, Rust YAML parsers and C# YAML parsers. To change a value in a file without disturbing anything else, see edit YAML without losing comments. All three oneyaml libraries, and how to install them, are on the oneyaml home page. Library versions on this page are the latest published on 26 October 2026.