AnsibleX · YAML for Reliable AutomationYAML for reliable automation
Types you did not ask for
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
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.
- 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 }}"$ ansible-playbook -i localhost, yamltypes.ymlok: [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:
# 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.
# 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.
$ python3 -c 'import yaml,sys; [print(k, "->", repr(v), type(v).__name__) for k,v in yaml.safe_load(sys.stdin).items()]' < ids.ymla -> 7 int
b -> 8 int
c -> '08' str
d -> '09' strA 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.
$ ansible-playbook -i localhost, colon2.ymlTASK [Show them] ***************************************************************
ok: [localhost] => {
"msg": "url=http://example.com/health (str) window=750 (int) ratio=181 (int)"
}# 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:
| Value | Write it as |
|---|---|
| Anything that is conceptually text | 'NO', '1.10', '007', '12:30' |
| A genuine boolean | true / false, unquoted, lowercase |
| A genuine number | unquoted, no leading zero |
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
Q1. A group_vars file contains maintenance_window: 12:30 with no quotes. What is the value of maintenance_window?
Q2. Which of these unquoted values parse as something other than a string? Select all that apply.
Q3. Quoting a scalar removes it from implicit type resolution entirely, so a quoted value is always a string.
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.