Skip to main content
RunBook Academy

AnsibleX · YAML for Reliable AutomationYAML for reliable automation

Types you did not ask for

Intermediate⏱ ~18 minansible-playbook

What you'll learn

  • Predict the parsed type of an unquoted YAML scalar
  • Recognise the boolean, float, octal, sexagesimal and date conversions
  • Explain the operational symptom each conversion produces
  • Use type_debug to inspect what a variable actually is
  • Apply the rule: quote anything whose type you care about

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

You do not choose the type of a YAML value. The parser does, by pattern matching the text, and its patterns are older and stranger than most people expect.

None of what follows is an Ansible behaviour. It is the YAML 1.1 type resolution that PyYAML implements, and it applies equally to a Compose file, a Kubernetes manifest and a CI configuration. It is worth learning once.

The demonstration

Rather than assert any of this, run it. Here is a play with ten variables and one debug task that reports the parsed type of each.

Read-only / Safeask the parser directly
- name: What the parser actually does
hosts: localhost
gather_facts: false
connection: local
vars:
  enable_tls: yes
  java_version: 1.10
  file_mode: 0644
  file_mode_quoted: '0644'
  plain_mode: 644
  account: 007
  account_str: '007'
  release: 1.2.3
  empty_value:
  explicit_null: null
tasks:
  - name: Report the parsed type of every variable
    ansible.builtin.debug:
      msg: "{{ item.name }} = {{ item.value | string }} (type {{ item.value | type_debug }})"
    loop:
      - { name: enable_tls, value: "{{ enable_tls }}" }
      - { name: java_version, value: "{{ java_version }}" }
      - { name: file_mode, value: "{{ file_mode }}" }
      - { name: file_mode_quoted, value: "{{ file_mode_quoted }}" }
      - { name: plain_mode, value: "{{ plain_mode }}" }
      - { name: account, value: "{{ account }}" }
      - { name: account_str, value: "{{ account_str }}" }
      - { name: release, value: "{{ release }}" }
      - { name: empty_value, value: "{{ empty_value }}" }
      - { name: explicit_null, value: "{{ explicit_null }}" }
    loop_control:
      label: "{{ item.name }}"
Read-only / Safeexecuted against ansible-core 2.21.3
$ ansible-playbook -i localhost, yamltypes.yml
ok: [localhost] => (item=enable_tls) => {
  "msg": "enable_tls = True (type bool)"
}
ok: [localhost] => (item=java_version) => {
  "msg": "java_version = 1.1 (type float)"
}
ok: [localhost] => (item=file_mode) => {
  "msg": "file_mode = 420 (type int)"
}
ok: [localhost] => (item=file_mode_quoted) => {
  "msg": "file_mode_quoted = 0644 (type str)"
}
ok: [localhost] => (item=plain_mode) => {
  "msg": "plain_mode = 644 (type int)"
}
ok: [localhost] => (item=account) => {
  "msg": "account = 7 (type int)"
}
ok: [localhost] => (item=account_str) => {
  "msg": "account_str = 007 (type str)"
}
ok: [localhost] => (item=release) => {
  "msg": "release = 1.2.3 (type str)"
}
ok: [localhost] => (item=empty_value) => {
  "msg": "empty_value = None (type NoneType)"
}
ok: [localhost] => (item=explicit_null) => {
  "msg": "explicit_null = None (type NoneType)"
}

Now take them one at a time, because each has its own symptom.

Booleans from words

yes, no, on, off, true, false, y, n and their capitalised and uppercase variants are candidates for boolean resolution. In PyYAML as used by ansible-core 2.21.3, yes/no/on/off/true/false and their case variants resolve to booleans; single-letter y and n do not, and remain strings.

The symptom. You write a country code, a two-letter language tag, or a person’s initials:

Read-only / Safethe country code that became a boolean
# Wrong
site_country: NO          # -> False
default_answer: yes       # -> True
feature_toggle: off       # -> False

# Right
site_country: 'NO'
default_answer: 'yes'
feature_toggle: 'off'

The failure is rarely a crash. False rendered into a template produces the string False, so the config file that should say country = NO says country = False, and the application starts fine and does the wrong thing.

Floats from version numbers

1.10 is a number with one decimal point, so it is a float, so it is 1.1. The trailing zero is gone and it is not coming back.

1.2.3 has two decimal points and matches no numeric pattern, so it stays a string — which is why this trap only appears on two-component versions, and therefore appears at the worst possible time: when a project ships 1.10 after 1.9.

Read-only / Safeversion pinning that unpins itself
# Wrong: resolves to the float 1.1
app_version: 1.10

# Right
app_version: '1.10'

The symptom. A package install that succeeds and installs the wrong version, or a package install that fails with “no candidate for version 1.1” months after the line was written and reviewed.

Octal from leading zeros

A leading zero followed by valid octal digits is an octal integer. 0644 is 420. 0755 is 493. 0600 is 384.

007 is octal 7, which is decimal 7 — so an account number, a ticket reference or a zero-padded ID loses its padding. And 08 is not valid octal, so it stays a string, which means a list of zero-padded values can contain a mixture of integers and strings depending on their digits.

Read-only / Safethe mixed-type list
$ python3 -c 'import yaml,sys; [print(k, "->", repr(v), type(v).__name__) for k,v in yaml.safe_load(sys.stdin).items()]' < ids.yml
a -> 7 int
b -> 8 int
c -> '08' str
d -> '09' str

A list of zero-padded identifiers can therefore contain both integers and strings, decided by whether each individual value happens to contain an 8 or a 9. Sorting, comparing and templating that list all behave inconsistently across its own elements.

File modes get their own lesson later in this part, because the consequences there are permission bits rather than a wrong string.

Base 60, from a colon

This is the conversion nobody predicts, and it is real.

YAML 1.1 defines a sexagesimal integer form: digits separated by colons are read as a base-60 number. So a maintenance window, a duration, or a ratio becomes an integer nobody recognises.

Read-only / Safeexecuted against ansible-core 2.21.3
$ ansible-playbook -i localhost, colon2.yml
TASK [Show them] ***************************************************************
ok: [localhost] => {
  "msg": "url=http://example.com/health (str) window=750 (int) ratio=181 (int)"
}
Read-only / Safethe values that produced that
# Wrong
window: 12:30     # -> 750
ratio: 3:1        # -> 181

# These happen to survive, which is worse than failing consistently
mac: 00:1B:44     # letters in a group, so no match
port_pair: 00:11  # leading zero in the first group, so no match

# Right: quote every one of them
window: '12:30'
ratio: '3:1'
mac: '00:1B:44'
port_pair: '00:11'

The sexagesimal pattern requires the first group to start with a digit 1 to 9 and every later group to be a valid 0-59 value, which is why 00:11:22 stays a string while 12:30 does not. That inconsistency is the trap: a column of similar-looking values in which some are integers and some are strings, decided by their leading digit.

The symptom. A maintenance window that renders as 750 in a config file, or a when: window == '12:30' that is never true because the variable is an integer.

Dates, and the empty value

2026-08-11 unquoted parses as a date object, not a string. Rendered into a template it usually looks right, but comparisons against a string fail and date arithmetic behaves unexpectedly.

An empty value and an explicit null both produce None. That matters for a reason the injection lesson already made concrete: None is defined, so it does not raise an undefined-variable error, and whether it renders as the empty string or as the literal text None depends on the filter chain it passes through.

The rule

Quote anything whose type you care about.

That is a shorter rule than the list of traps, and it subsumes all of them. In practice it produces three habits:

ValueWrite it as
Anything that is conceptually text'NO', '1.10', '007', '12:30'
A genuine booleantrue / false, unquoted, lowercase
A genuine numberunquoted, no leading zero
Read-only / Safesettle the argument in ten seconds
ansible -i localhost, -c local localhost -m ansible.builtin.debug \
-e @group_vars/all.yml \
-a "msg={{ app_version | type_debug }} {{ app_version }}"

Knowledge check

Knowledge check · 4 questions

  1. Q1. A group_vars file contains maintenance_window: 12:30 with no quotes. What is the value of maintenance_window?

  2. Q2. Which of these unquoted values parse as something other than a string? Select all that apply.

  3. Q3. Quoting a scalar removes it from implicit type resolution entirely, so a quoted value is always a string.

  4. Q4. A playbook pins app_version: 1.10 and the package task installs 1.1 without erroring. Why did nothing catch this?

Passing score: 75%. Answers are checked in this browser.