Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

YAML and Jinja in Ansible: Gotchas and what you need to know

These technologies all seem simple on the surface, and easy enough to learn through examples. But once we start using them in more complex ways, it’s important to make sure our understanding is deep and formal.

Please note: much of the information here might seem redundant or obvious, but this post is meant for a wide audience, and seeks to emphesize some info usually taken for granted or widely misunderstood! So please don’t feel condescended to if you’ve been linked here.

Some info also might be oversimplified-- this document is meant to help you learn Enough To Get By, not to be an expert.

What is YAML?

YAML is a:

  1. Standardized

  2. Serialization Language.

Let’s review what I mean by that.

It’s Standardized

There is a formal specification for it. It’s not just some slapdash assortment of various libraries for working with something that looks YAML-ish[1]

But you don’t have to read the whole thing! The wonderous Learn X in Y minutes where X = YAML is good enough!

There is also the wonderful YAML Multiline Strings site, which will be important later.

It’s a Serialization Language

It is a way to encode almost any data structure you can think of, but we’re basically using it as an easier-to-write-and-read JSON[2].

Essentially, you can model any data structure using a combination of these data types which YAML supports (not exhaustive):

Many programming languages support more than just the above-- they might have formal classes / structs, etc.

YAML allows you to say, e.g. “this mapping is actually a structure called $BLANK” through a feature called “tags”. You probably won’t see this often, but it’ll be important later!

Here’s a python example:

class Person:
    name: str
    age: int
maintainer: !!person
  name: Yaml McYamlface
  age: 21 # yaml 1.0 was January 2004!

YAML rules to know about

The Three Kinds of Strings

One of YAML’s major strengths over JSON is how you only need to quote strings if they include certain special characters. The values of the following mapping are all the same:

plain-style: Hello world!
single-quotes: 'Hello world!'
double-quotes: "Hello world!"

What if you have a really really long string, that would make the document hundreds of characters wide? You can make a string go over multiple lines with quotes, and we’ll get the markdown-like behavior of “a newline is a space, and two newlines is a newline”, but it feels weird, right?

multiple-lines: "This string goes
  over multiple lines."

There’s a nicer way to express this:

text-block: >
  This will be a
  single line of text, but...

  This is a new line.
# but it keeps the trailing newline, so text-block
# ends with 'new line.\n'

# we can "chomp / strip" the end with `-`:
no-trailing: >-
  Look ma, no newline!

Inline Mappings and Lists

They exist! These are valid:

inline-mapping: {name: Foo, age: 100}
inline-list: [1, 2, 3]

Keep this in mind for later.

What is Jinja?

Jinja is a templating language. It also has a handy Learn X in Y Minutes where X = Jinja page.

The short version of what you need to know is that expressions inside {{ }} get evaluated. If you have a variable food set to tacos, then running Let's eat {{ food }} tonight through jinja will get you Let's eat tacos tonight.

How does Ansible use YAML and Jinja?

YAML resources (playbooks, tasks files, vars files) in the YAML format.
It can reference variables using Jinja.
Here’s a simple version of what that looks like:

- name: An example playbook
  hosts: localhost
  vars:
    whomst: world
  tasks:
    - name: Say hello
      ansible.builtin.debug:
        msg: Hello {{ whomst }}!

Essentially, this happens in two phases:

  1. Ansible parses the YAML to get the structure of the playbook.

  2. When it runs tasks, it will evaluate each argument, potentially evaluating jinja expressions.

A skirmish of syntaxes

What if we want to template the greeting too?

1
2
3
4
5
6
7
8
9
- name: An example playbook
  hosts: localhost
  vars:
    greeting: Hola
    whomst: world
  tasks:
    - name: Say hello
      ansible.builtin.debug:
        msg: {{ greeting }} {{ whomst }}!

That last line is an issue. How can YAML parse this bit?

msg: {{ greeting }} {{ whomst }}!
     ^ well there's your problem.

YAML sees a { and gets ready for an inline mapping that never comes. You might ask: why it doesn’t just look ahead one character to see it’s a jinja expression? The answer is because YAML doesn’t know anything about jinja! That’s ansible-specific.

What if ansible added special support for that case? Well, then it wouldn’t be YAML anymore. Any and all tooling that expects YAML would have to be changed. We already said we’re using something standardized, not “something that happens to look like YAML”.

Ansible is nice and will tell you what the error is:

The offending line appears to be:

      ansible.builtin.debug:
        msg: {{ greeting }} {{ whomst }}!
                            ^ here
We could be wrong, but this one looks like it might be an issue with
missing quotes. Always quote template expression brackets when they
start a value. For instance:

    with_items:
      - {{ foo }}

Should be written as:

    with_items:
      - "{{ foo }}"

Now the YAML parser knows it’s a string, and Ansible will still happily evaluate the jinja expression when calling the debug module.

When and how does Jinja get evaluated?

Ansible will evaluate jinja lazily and recursively. Take a look at these files:

vars1.yaml
static1: static1 value
ref2: Referencing {{ static2 }}
vars2.yaml
static2: static2 value
ref1: Referencing {{ static1 }}
playbook.yaml
- name: An example playbook
  hosts: localhost
  vars_files:
    - vars1.yaml
    - vars2.yaml
  tasks:
    - ansible.builtin.debug:
        msg: "{{ ref1 }}"
    - ansible.builtin.debug:
        msg: "{{ ref2 }}"

Not only do you have variables referencing other variables in other files, but variables referencing variables get used as variables! ref1 -> static1 -> static1 value works, in other words.

Where the intersection gets messy

Where “messy” means hard to think about at first.

A review of two modules

Ansible can template arbitrary files / jinja expressions and put the output into a file. It’s commonly used for config files.

config.j2
{{ user }} = very cool
playbook.yaml
- name: An example playbook
  hosts: all
  vars:
    user: You
  tasks:
    - name: Template some config file
      ansible.builtin.template:
        src: config.j2
        dest: /etc/user-coolness
        owner: root
        group: wheel
        mode: 'o=rw,g=rw,a=r'

You can include vars files dynamically, instead of just using vars at the playbook level.

dev.yaml
env_name: Development
prod.yaml
env_name: Production
playbook.yaml
- name: An example playbook
  hosts: "{{ deploy_env }}"
  tasks:
    - name: Include variables for environment
      ansible.builtin.include_vars:
        file: "{{ deploy_env }}.yaml"
    - name: Print environment name
      ansible.builtin.debug:
        msg: "Deploying into {{ env_name }}"

Now we’re ready to tackle the confusing bit.

When is YAML not YAML?

Answer: when you don’t treat it as such. A YAML file is text, and can just be treated as text. ansible.builtin.template doesn’t care about whatever syntax its input file is, essentially all it looks for is {{ or {% and their matching closers, and then handles whatever is between. It doesn’t care if it happens to be valid YAML or Shakespeare, it just processes what’s in there and spits it out.

Remember how we said this wasn’t valid yaml?

template.yaml.j2
howdy: {{ greeting }} {{ whomst }}!

template doesn’t care! It’s still a valid jinja template.

playbook.yaml
- name: Template a file, don't care what format
  hosts: localhost
  vars:
    greeting: Hola
    whomst: world
  tasks:
    - name: Template
      ansible.builtin.template:
        src: template.yaml.j2
        dest: out.yaml

Running this will output a completely valid yaml file:

out.yaml
howdy: Hola world!

Please don’t do this if you can avoid it.

Who templates the templatemen?

Okay, but what if you want to template a vars file? Maybe you have an automated process that creates a vars file per resource in some other system. A perfectly cromulent use case, maybe.

This is a contrived example, but let’s pretend it’s a structure like vars/users/username.yaml. It’ll contain fields for GECOS data and the like. But we want it to reference certain standard groups, no matter the user.

Here’s what a finished, rendered user record would look like:

vars/users/kevin.yaml
full_name: Kevin M Granger
employee_id: 12345
office_phone: "1-555-867-5309"
groups: '{{ standard_groups + other_groups }}'

We might use a template like this:

vars/users.skel.yaml
full_name: "{{ full_name }}"
employee_id: "{{ employee_id }}"
office_phone: "{{ office_phone }}"
groups: !unsafe "{{ standard_groups + other_groups }}"

!unsafe is a tag that tells ansible that the value should not be evaluated by jinja. You’ll see what that results in below. This happens at the Ansible level. It doesn’t change how YAML parses it, but the tag is passed from the YAML parser to Ansible itself.

See the ansible docs for !unsafe for details.

Cconsume it like so:

onboard_user.yaml
- name: Onboard a user
  hosts: localhost
  vars_prompt:
    - name: username
      prompt: Username?
      private: false
    - name: full_name
      prompt: Full name?
      private: false
    - name: employee_id
      prompt: Employee ID?
      private: false
    - name: office_phone
      prompt: Office phone number?
      private: false
  tasks:
    - name: Fill out user profile
      ansible.builtin.include_vars:
        file: vars/users.skel.yaml
        name: new_user

    - name: Save user profile
      ansible.builtin.copy:
        content: "{{ new_user | to_nice_yaml(sort_keys=false) }}"
        dest: vars/users/{{ username }}.yaml

Then inputting the expected values will give you:

vars/users/kevin.yaml
full_name: Kevin M Granger
employee_id: '12345'
office_phone: '1-555-867-5309'
groups: '{{ standard_groups + other_groups }}'

Do you see what happened to groups? !unsafe told ansible that the data was plain 'ol YAML data, nothing more and nothing less.

That also means when it gets spat back out into a file, it’s serialized as the string it is-- meaning if it’s read in again, it’ll be evaluated as Jinja!

In this case, that’s exactly what we want. But keep that in mind.

Waitaminute.

employee_id: '12345'

That’s a string, not a number. Jinja used to only return strings from its templates. They eventually added support for keeping the types in the template’s output, but that’s a backwards-incompatible change, so it’s disabled by default. You can change the setting in either ansible.cfg or through the environment variable.

Lessons Learned

Footnotes
  1. Buggy implementations aside 🙂

  2. It’s technically not a superset of JSON, as many claim. Mainly (exclusively?) due to how leading whitespace is handled. Still, it’s unlikely this’ll ever be an issue.