DevOps & CI/CD Bug Report

YAML Norway problem: country code NO parsed as false and 0755 as 493

Our deploy tool crashed with 'bool' object has no attribute 'upper' the day we added Norway to a YAML list. YAML 1.1 implicit typing turned NO into false and 0755 into 493. Here is how we found it, fixed it and stopped it coming back.

John Nguyen John Nguyen @devbugs · April 29, 2025 · 9 min read · 35 Views
Banner: YAML list where the country code NO silently becomes the boolean false

The release pipeline for our storefront config failed ten minutes after a product manager merged a one-line change: the Nordic expansion added Norway to the list of shipping countries. Our deploy tool, a small Python CLI that renders per-country settings, crashed during validation:

Traceback (most recent call last):
  File "/app/deploytool/countries.py", line 41, in normalise_codes
    return [code.upper() for code in raw]
            ^^^^^^^^^^
AttributeError: 'bool' object has no attribute 'upper'

The diff that triggered it was tiny:

shipping:
  countries: [GB, IE, SE, DK, FI, NO]

Reproducing it

The crash reproduced locally in one line, without the rest of our tool:

python3 -c 'import yaml; print(yaml.safe_load("countries: [GB, SE, NO]"))'
{'countries': ['GB', 'SE', False]}

The loader did not treat NO as a two-letter string. It returned the Python boolean False.

What we ruled out

  • A bad merge or invisible characters. We ran cat -A on the file and saw only plain ASCII; the line was exactly what the reviewer approved.
  • Our own normalisation code. It had been unchanged for a year and worked for every other code. The value was already a bool before our function saw it.
  • A PyYAML regression. We pinned PyYAML 6.0.2 months earlier, and the same behaviour exists in every PyYAML release. It is not a bug in the library; it is the spec it implements.

While we were reading the rest of the file we found a second, quieter problem in the same config:

artifacts:
  cache_dir_mode: 0755

Our tool passed that value to os.chmod. The loaded value was the integer 493, which happens to be the decimal value of octal 755, so the directory got the right mode by luck. A neighbouring service that wrote the value into a JSON settings file stored 493, and an operator later "corrected" it to 755 decimal, giving mode 1363 octal. That one had been wrong in staging for weeks.

Root cause

PyYAML implements YAML 1.1. In YAML 1.1 an unquoted plain scalar is matched against implicit type resolvers before it is treated as a string. The boolean resolver accepts y, Y, yes, Yes, YES, n, N, no, No, NO, true, false, on, off and their case variants (PyYAML skips the single-letter forms but accepts the rest). The integer resolver accepts a leading 0 as octal. So NO resolves to False and 0755 resolves to 493. Every other country code in our list just happened to miss the resolver table.

What is happening under the hood

A YAML loader assigns a tag to every node. Quoted scalars are always strings. Plain (unquoted) scalars go through a set of regular expressions, and the first match decides the type. YAML 1.1 shipped a generous table: booleans in many spellings, octal with a leading zero, sexagesimal numbers such as 1:30 (parsed as 90), and the << merge key.

YAML 1.2, published in 2009, cut this down. The 1.2 core schema only treats true/false (and capitalised variants) as booleans, and octal needs an explicit 0o prefix. Under 1.2, NO is a string and 0755 is the integer 755.

The catch is that many popular loaders still default to 1.1 behaviour:

Loader Default behaviour for NO and 0755
PyYAML 6.0.x (yaml.safe_load) YAML 1.1: False, 493
ruamel.yaml 0.18 (YAML(typ="safe")) YAML 1.2: "NO", 755
Go gopkg.in/yaml.v3 into interface{} "NO" stays a string; yes/no only become bools when decoding into a typed bool field
Ruby Psych YAML 1.1 style booleans

That table explains why the same file behaved differently in two of our services: the Go service read the country list correctly, the Python tool did not.

The same mechanism is why many people see a GitHub Actions workflow key on: turn into True when they load the file with PyYAML. GitHub's own parser handles it, but a Python linter or generator reading the workflow sees a boolean key.

Environment

Component Version / value
OS Ubuntu 24.04 LTS (CI runner and dev laptops)
Runtime Python 3.12.3
YAML library PyYAML 6.0.2, yaml.safe_load
Linter (before fix) none on config files
Config files about 60 YAML files, hand-edited by engineers and product

Minimal reproduction

import yaml

doc = """
countries: [GB, SE, NO, "NO"]
flags: [yes, on, off, n]
mode: 0755
duration: 1:30
"""
print(yaml.safe_load(doc))
{'countries': ['GB', 'SE', False, 'NO'], 'flags': [True, True, False, 'n'], 'mode': 493, 'duration': 90}

The quoted "NO" survives. Note n stays a string in PyYAML even though the 1.1 spec lists it, which is one more reason not to rely on memory of the table.

Who is affected

Anyone loading hand-written YAML with a 1.1 loader where values are free-form strings: country and language codes (NO for Norway, no for Norwegian), feature names like off, version strings like 1.10 (becomes float 1.1), postal codes or IDs with leading zeros, and file modes. Tools that read the same YAML in different languages are especially exposed because they disagree silently.

The fix

We made three changes, in order of how fast they shipped.

1. Quote values that must be strings. The hotfix was quoting the list. We quoted every code, not only Norway, so the next person copies a consistent pattern:

 shipping:
-  countries: [GB, IE, SE, DK, FI, NO]
+  countries: ["GB", "IE", "SE", "DK", "FI", "NO"]
 artifacts:
-  cache_dir_mode: 0755
+  cache_dir_mode: "0755"

The mode became a string on purpose; the tool now parses it explicitly with int(value, 8), so there is no doubt about the base.

2. Validate types at the boundary. The crash was actually the good outcome. The dangerous case is a bool that flows silently into a database. We added a schema step right after loading, using Pydantic v2 in strict mode so a bool is never coerced into a string:

from pydantic import BaseModel, ConfigDict, field_validator
import yaml


class Shipping(BaseModel):
    model_config = ConfigDict(strict=True)
    countries: list[str]

    @field_validator("countries")
    @classmethod
    def iso_alpha2(cls, codes: list[str]) -> list[str]:
        for code in codes:
            if len(code) != 2 or not code.isalpha():
                raise ValueError(f"not an ISO 3166 alpha-2 code: {code!r}")
        return [c.upper() for c in codes]


def load_shipping(path: str) -> Shipping:
    with open(path, encoding="utf-8") as fh:
        data = yaml.safe_load(fh)
    return Shipping.model_validate(data["shipping"])

With the unquoted file, this now fails at load time with a clear message that the list item should be a valid string, pointing at index 5, instead of crashing deep inside the renderer.

3. Lint every YAML file in CI. yamllint has rules for exactly this class of problem. Our .yamllint.yaml:

extends: default
rules:
  truthy:
    allowed-values: ["true", "false"]
    check-keys: false
  octal-values:
    forbid-implicit-octal: true
    forbid-explicit-octal: true
  line-length: disable

truthy flags any yes, no, on, off and capitalised variants that are not true/false. octal-values flags 0755 and 0o755. We set check-keys: false so workflow files with an on: key do not fail.

Alternatives and trade-offs

  • Switch the Python loader to ruamel.yaml. YAML(typ="safe", pure=True) loads YAML 1.2 by default and keeps NO as a string. We did this for the deploy tool. The trade-off is that other consumers of the same files still use 1.1 loaders, so quoting remains the portable fix.
  • A custom PyYAML loader with a reduced bool resolver. Possible by copying SafeLoader and rebuilding yaml_implicit_resolvers without the yes/no/on/off pattern. It works, but it is clever code that the next maintainer will not expect. We rejected it.
  • Move config to TOML or JSON. Both have explicit types and no implicit booleans. That is a bigger migration than the bug justified; we may revisit it for new files.

How to test the fix

A regression test that loads the real config files and asserts types:

import pathlib
import yaml

CONFIG_DIR = pathlib.Path("config")


def test_country_codes_are_strings():
    for path in CONFIG_DIR.glob("**/*.yaml"):
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        codes = data.get("shipping", {}).get("countries", [])
        bad = [c for c in codes if not isinstance(c, str)]
        assert not bad, f"{path}: non-string country codes {bad}"

And the lint run, locally and in CI:

pip install "yamllint==1.35.1"
yamllint --strict -c .yamllint.yaml config/
pytest tests/test_config_types.py -q

Before the quoting change, yamllint reported the problem directly:

config/storefront.yaml
  2:35      warning  truthy value should be one of [false, true]  (truthy)
  4:19      error    forbidden implicit octal value "0755"  (octal-values)

Confirming in production

After the fix deployed we checked the rendered output rather than trusting the source file:

deploytool render --env prod --section shipping | jq '.countries'
["GB", "IE", "SE", "DK", "FI", "NO"]

The Norway storefront checkout accepted Norwegian addresses on the first try, and we cross-checked the cache_dir_mode on the hosts with stat -c '%a' /var/cache/storefront, which returned 755 everywhere, including the staging host that had been running with the wrong mode.

Regression guard

  • yamllint runs in the pre-commit hook and as a required CI check on every path matching *.yaml and *.yml.
  • The strict Pydantic model is the only way the tool reads the shipping section, so a non-string value fails at load time.
  • The type test above runs on every pull request.

Security implications

The country-code bug itself was a correctness issue that failed closed (a crash). The octal issue was a real security concern: the misconfigured staging directory ended up with mode 1363, which made it world-writable (with the sticky bit). Anything under that cache path could have been replaced by another local user. We rotated the cached artifacts on staging and confirmed production was never affected. Implicit typing can also flip feature flags: a value like require_mfa: off meant as a string label becomes False in a 1.1 loader. Treat YAML that controls permissions or security flags as code and lint it.

Prevention checklist

  • Quote every value that must stay a string: codes, versions, IDs, modes.
  • Validate config with a strict schema right after loading; never pass raw loader output around.
  • Enable yamllint truthy and octal-values in CI.
  • Prefer a YAML 1.2 loader (ruamel.yaml) in Python tools you own.
  • Parse file modes from strings with an explicit base.

References

Add a label

#python #ci #yaml #pyyaml #yamllint
Written by
John Nguyen @devbugs

I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.