Recursive AI

The YAML Norway problem

The YAML Norway problem explained: why country: NO reads as false, which Go, Rust and C# YAML libraries still do it, and how to read and write YAML safely.

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:

yaml.org/type/bool.htmlYAML 1.1
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 scalarYAML 1.1YAML 1.2 Core schema
NO, no, n, offboolean falsestring
yes, y, onboolean truestring
true, Falsebooleanboolean
0755octal, the integer 493the integer 755
0o755stringoctal, the integer 493
1_000the integer 1000string
22:30base 60, the integer 1350string

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:

main.goGo 1.26
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"])
    }
}
Output
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:

main.goGo 1.26
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])
    }
}
Output
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 a string field: the country code is lost even with a typed struct.
  • go.yaml.in/yaml/v3 v3.0.5 reads yes as true into a bool field, as its README says (YAML 1.1 booleans “are supported as long as they are being decoded into a typed bool value”), and 0755 as octal 493.
  • goccy/go-yaml v1.19.2 refuses yes for a bool but reads 0755 as 493.
  • oneyaml.dev/go v1.0.0 follows the Core schema in both cases: yes into a bool is an error with its line and column (line 1, column 10: /enabled: cannot unmarshal !!str `yes` into bool), and 0755 is 755.

Rust: the serde_yaml family and serde-saphyr

The same three lines, read into typed fields:

main.rsRust 2024
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()));
}
Output
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

Program.cs.NET 10
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; }
}
Output
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():

main.gooneyaml.dev/go v1.0.0
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)
    }
}
Output
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:

main.rsoneyaml 1.2.0
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);
}
Output
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.