Skip to main content
RunBook Academy

AnsibleX · YAML for Reliable AutomationYAML for reliable automation

Quoting, and the double-brace rule

Intermediate⏱ ~16 minansible-playbook

What you'll learn

  • Explain why an unquoted value starting with a template is a YAML parse error
  • Choose between single and double quotes for a value containing a template
  • Distinguish what the YAML parser sees from what Jinja receives
  • Write when: conditions without templating delimiters
  • Recognise when a template must be quoted and when it need not be

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.

This is the error every Ansible user meets in their first week, and it is worth understanding rather than memorising, because the understanding transfers to several other situations and the memorised rule does not.

Why the parser objects

In YAML, { at the start of a value opens a flow mapping — the inline {key: value} form. So when you write:

Read-only / Safewhat you wrote
    - name: Print the path
    ansible.builtin.debug:
      msg: {{ app_root }}/current

…the parser sees a value starting with {, decides it is looking at a flow mapping, and then fails, because {{ app_root }}/current is not a valid one. Jinja is never consulted; the file never gets that far.

Read-only / Safethe actual error, and the actual advice
$ ansible-playbook --syntax-check -i localhost, jinja-unquoted.yml
[ERROR]: YAML parsing failed: This may be an issue with missing quotes around a template block.
Origin: /home/ops/estate/jinja-unquoted.yml:10:14

8     - name: Print the path
9       ansible.builtin.debug:
10         msg: {{ app_root }}/current
              ^ column 14


For example:

  raw: {{ some_var }}

Should be:

  raw: "{{ some_var }}"

The fix is quotes, and the rule is narrower than most people state it:

A value must be quoted when the template appears at the start of the value.

msg: The path is {{ app_root }}/current parses fine unquoted, because the value starts with T. That is why the rule is often learned as “sometimes you need quotes” — the failure depends on position, not on the presence of a template.

Which quote style

Both single and double quotes produce a string. They differ in what they do with backslashes and with the other quote character.

StyleEscapesUse when
'single'Only '' for a literal '. Backslashes are literal.The default. Especially for anything containing \ — Windows paths, regular expressions.
"double"Full escape processing: \n, \t, \\, \".You want an escape sequence interpreted, or the value contains a single quote.

The practical decision is usually made for you by the template’s own contents, because Jinja string literals need quotes too:

Read-only / Safepicking the outer quote to suit the inner one
# Jinja needs single quotes inside, so the YAML string uses double.
- name: Report the environment
ansible.builtin.debug:
  msg: "{{ app_env | default('staging') }}"

# Jinja needs double quotes inside, so the YAML string uses single.
- name: Split on a quoted delimiter
ansible.builtin.debug:
  msg: '{{ csv_line.split(",") | first }}'

# A regular expression: single-quoted YAML keeps the backslashes literal,
# so \. and \1 arrive at the filter exactly as written.
- name: Extract the major version
ansible.builtin.set_fact:
  major: '{{ app_version | regex_replace("^([0-9]+)\..*$", "\1") }}'

Executed against 2.21.3 with app_version: '1.10.4', that set_fact produces major: "1".

What the parser sees, and what Jinja receives

{{ var }} and "{{ var }}" are different to the YAML parser — one is a parse error and one is a string. To Jinja they are identical, because by the time Jinja is involved the quotes have already been consumed by the parser and the value is the six-plus characters {{ var }}.

That distinction explains something people find inconsistent: whether a templated value keeps its type.

Read-only / Safea whole-value template preserves the type
$ ansible-playbook -i localhost, looptype.yml
TASK [Report the type that survived] *******************************************
ok: [localhost] => {
  "msg": "templated_mode=420 type=int"
}

TASK [Report that type too] ****************************************************
ok: [localhost] => {
  "msg": "concat_mode=mode-420 type=str"
}

So "{{ port }}" where port is the integer 8080 yields the integer 8080, while "port-{{ port }}" yields the string port-8080. The quotes are the YAML parser’s business; the type is decided by whether the template is the whole value.

The when: exception

when takes a Jinja expression without delimiters. Not a string containing a template — the raw expression.

Read-only / Safewhen takes the expression itself
- name: Restart only where it is needed
ansible.builtin.systemd_service:
  name: app
  state: restarted
when: app_state == 'present' and replicas | int > 1

- name: Skip hosts that opted out
ansible.builtin.debug:
  msg: 'running'
when:
  - not maintenance_exempt | default(false)
  - ansible_facts['os_family'] == 'Debian'

A list under when: is an implicit and of every element, which is almost always more readable than one long expression joined with and.

Wrapping the expression in braces still works, and produces a deprecation warning:

Read-only / Safethe deprecation, executed
$ ansible-playbook -i localhost, whenraw.yml
TASK [Braces in when, quoted] **************************************************
[DEPRECATION WARNING]: Conditionals should not be surrounded by templating delimiters
such as {{ }} or {% %}. This feature will be removed from ansible-core version 2.23.
Origin: /home/ops/estate/whenraw.yml:17:13

15       ansible.builtin.debug:
16         msg: 'braced expression ran'
17       when: "{{ replicas > 1 }}"
             ^ column 13

ok: [localhost]

That is a dated removal, not a style note. A repository full of when: "{{ ... }}" has a migration ahead of it, and the fix is mechanical: delete the braces and the quotes.

The same rule applies to the other keywords that take expressions directly — changed_when, failed_when, until, and assert’s that: list. All of them are raw Jinja.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why does msg: {{ app_root }}/current fail to parse, while msg: The path is {{ app_root }}/current does not?

  2. Q2. Which keywords take a raw Jinja expression with no templating delimiters? Select all that apply.

  3. Q3. Because "{{ port }}" is quoted in the YAML, the value reaching the module is always a string.

  4. Q4. A regex_replace pattern containing backslash escapes is written inside a double-quoted YAML scalar and matches nothing. What is the likely cause?

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