Skip to content
datahacks.dev
YAML

YAML Version Numbers Turning Into Floats and How to Stop It

Python 3.10 in a CI matrix loads as 3.1 in every parser. See the proof, why quoting is the fix and a script that finds suspect floats in a repository.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

A GitHub Actions workflow tests on Python 3.9, 3.10, 3.11 and 3.12. A week later someone notices that the "3.10" job has been running Python 3.1 and failing to find an interpreter, or worse, running some other version silently. The workflow was valid YAML. It was read exactly as written. The text 3.10 is a decimal number, and numbers do not remember their trailing zeros. This post shows the effect in a real workflow, why no parser avoids it and how to find every instance in a repository.

The workflow

YAML
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: [3.9, 3.10, 3.11, "3.12"]
        os: [ubuntu-latest, windows-latest]

Loaded with both parsers:

Text
PyYAML matrix python: [3.9, 3.1, 3.11, '3.12']
ruamel matrix python: [3.9, 3.1, 3.11, '3.12']

The second entry is 3.1. The quoted "3.12" is a string, and 3.11 is the float 3.11 (which prints the same, so it is fine by luck). If the job template builds python-${{ matrix.python }} from the loaded value, the job for 3.10 asks for 3.1. Tools that read workflow files hand the value to the runner as text after converting the float, so what you see in the job name is 3.1.

Why no parser fixes it

3.10 is a valid decimal literal in YAML 1.1 and in 1.2. The core schema defines a float as digits, a dot, digits, so the parser's resolution gives a float, and floats are numbers. The number 3.10 and the number 3.1 are the same number. The parser converts the text to a floating point value and the text is gone. Switching to a 1.2 parser, as the comparison above shows, changes nothing here. This is different from the boolean and octal problems, which a 1.2 parser does fix (YAML 1.1 versus 1.2).

Which strings are affected? Only text that is a single number with one dot:

WrittenLoads asNote
3.103.1The trailing zero is lost
20.1020.1Node-style versions
0.500.5Also prices and ratios
1.01.0Looks fine, is a float not a string
1.20.0'1.20.0'Two dots, not a number, stays a string
3.93.9Fine by luck
33An integer

Semantic versions with three parts (1.20.0) are safe because they are not numbers. Two-part versions are the danger: Python (3.10), Node (20.10, 18.19), Ruby, Go (1.20), Postgres (14.10), Docker image tags without a patch number. The danger grows over time: 3.9 works and 3.10 does not, which is why these bugs appear when a project adds its tenth minor version.

The fix: quote it

YAML
python: ["3.9", "3.10", "3.11", "3.12"]

A quoted scalar is a string in every parser, and the value arrives as written. Quote all of them, including the ones that work today ("3.9"), so the list is consistent and adding "3.13" does not need a decision. Some tools document that they accept numbers for versions and convert internally; do not depend on it, because the conversion has already lost the zero.

For image tags and versions in other fields, the same applies:

YAML
image: postgres:14.10        # fine, contains a colon and is one string
version: "14.10"             # quote when it stands alone

postgres:14.10 is a plain string because of the colon followed by non-space (no space after the colon), so it is not a number. A bare 14.10 is.

Find them all

Fixing one file does not fix the next one somebody writes. A short script finds the suspicious floats in any YAML file. yaml.compose returns the node tree with the original text of each scalar, before type conversion, so the script can compare text and value:

floats.pyPython
import sys, yaml

def walk(n):
    if isinstance(n, yaml.ScalarNode):
        if n.tag.endswith(":float") and n.style is None and "." in n.value and n.value.split(".")[-1].endswith("0") and float(n.value) != 0:
            print(f"suspicious float {n.value!r} at line {n.start_mark.line+1}: loads as {float(n.value)}")
    elif isinstance(n, yaml.SequenceNode):
        for c in n.value: walk(c)
    elif isinstance(n, yaml.MappingNode):
        for k, v in n.value: walk(v)

for path in sys.argv[1:]:
    walk(yaml.compose(open(path)))

On a sample with a Python matrix, a Node version and some other numbers:

Text
suspicious float '3.10' at line 1: loads as 3.1
suspicious float '20.10' at line 2: loads as 20.1
suspicious float '1.0' at line 3: loads as 1.0
suspicious float '0.50' at line 5: loads as 0.5

The check flags floats whose written form ends in zero, which is where information is lost. It also flags 1.0 and 0.50, which may be legitimate (a ratio, a price), so treat the output as a list to review. The n.style is None test skips quoted scalars, which are already safe. Run the script over a repository with git ls-files '*.yml' '*.yaml' | xargs python3 floats.py, and add it to CI so new files are checked.

A schema is the long-term answer: declare the version field as a string, and a number fails validation. In JSON Schema that is "type": "string" with a pattern such as ^\d+\.\d+$ to enforce the shape.

What the tools do about it

Some CI systems document the problem in their own words (they tell you to quote versions). Some language-specific tools accept both forms and convert numbers by formatting with one decimal, which is wrong for 3.10. A few detect that a float was given and print a warning. None can recover the lost zero. The only fix is in your file.

Where else versions hide

The workflow matrix is the famous case, and the same mistake appears wherever a version is written in a configuration file. Language versions in CI files for Node, Ruby, Go, Java and Python. Runtime versions in deployment manifests. Database versions in test setups, where a service container is pinned as 14.10. Dependency constraints written as numpy: 1.20 that were meant as a specific release. Chart and package versions in files that tools read with a YAML parser, such as a Helm Chart.yaml, whose version field must be a semantic version string and fails validation when it is read as a number.

Fixes differ slightly by place. In CI files, quote. In dependency lists, prefer the syntax the tool defines for constraints ("==1.20" or ~=1.20), which are strings by nature. In Helm and Kubernetes manifests, where tools mostly apply their own schema, an unquoted value that parses as a float can be rejected with a type error such as "expected string, got number", which at least fails loudly.

A last habit helps people who review changes. When a pull request touches a version, ask whether the new value has a trailing zero, and whether the old one had a different number of parts. A version that moves from 3.9 to 3.10 is exactly where a float parse changes the result, and a reviewer who knows that can catch it in the diff.

The cluster hub is fixing YAML errors that make no sense. For the other shapes that change meaning, see why a number turns into something else in YAML, and for the quoting rules YAML strings, quoting and when you need quotes. The float pattern is defined in the YAML 1.2.2 core schema, and matrix builds in the GitHub Actions documentation. Check how a list loads in the YAML to JSON tool.

When quoting a version is not enough

The tool converts strings to numbers. If a downstream step parses the version with a numeric function, the zero is lost there. Check the tool, not only the YAML.

The version is generated. A script that writes the file must quote strings that look like numbers. Good YAML libraries do this on their own; string templates do not.

The comparison is numeric. Comparing 3.10 and 3.9 as numbers says 3.9 is larger. Versions need version-aware comparison, not floats, which is a problem for the code reading them.

Other files repeat the mistake. A lockfile, a Dockerfile argument or a Makefile may contain the same version without quotes. Search all of them.

Also inDebugging
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.