Understanding YAML and Ansible Playbooks

Understanding YAML and Ansible Playbooks
Table of Contents

YAML is a plain text format used to store and organize data. It's designed to be easy for humans to read and write, which is one of the main reasons Ansible uses it. The name originally stood for "Yet Another Markup Language", but it was later changed to "YAML Ain't Markup Language" to make it clear that it's meant for data, not documents.

In Ansible, almost everything you write is in YAML, including playbooks, inventory files, and variables. You've already seen two YAML files in the previous chapter, the inventory and the playbook.

If you've used Containerlab or Netlab, you've already worked with YAML, since their topology files are written in YAML too. It's also used by many other tools such as Docker Compose and Kubernetes, so learning it now will help you well beyond Ansible.

Here's a simple example of what YAML looks like.

hostname: r1
mgmt_ip: 192.168.202.101
ntp_servers:
  - 10.10.10.10
  - 10.10.10.11

Even if you've never seen YAML before, you can probably tell what this means. We have a device called r1, its management IP, and a list of two NTP servers.

YAML is simple, but it's also strict about formatting, especially indentation. When you're starting out with Ansible, most of the errors you'll run into are actually YAML errors rather than Ansible errors. So it's worth spending a bit of time on the basics before we go any further.

YAML Basic Rules

YAML doesn't have many rules, but I've learned the hard way that breaking even one of them means Ansible won't run your playbook. Here are the ones I think you really need to know.

Start with Three Dashes

YAML files usually start with three dashes ---. This marks the beginning of the document. It's optional, and your playbook will still work without it, but it's a common convention, and you'll see it in almost every Ansible example.

---
hostname: r1

Use Spaces, Never Tabs

YAML doesn't allow tabs for indentation. You must use spaces. The most common convention is two spaces for each level, which is what we'll use throughout this book.

This is something that catches a lot of people out because tabs and spaces look the same on screen. Most editors, including VS Code, can be set to insert spaces when you press the Tab key.

Indentation Defines the Structure

In YAML, indentation tells YAML how the data is structured, meaning which items belong to which. Items at the same level must be indented by the same number of spaces.

interface:
  name: Ethernet0/1
  description: uplink to r2

Here, name and description are both indented by two spaces, so YAML knows they both belong to interface. If description was indented by three spaces instead, you'd get an error.

Add a Space After the Colon

When you define a key and a value, there must be a space after the colon.

hostname: r1     # correct
hostname:r1      # wrong

YAML Is Case Sensitive

Hostname and hostname are treated as two different keys. Most Ansible keywords are lowercase, so it's best to stick with lowercase everywhere.

Comments

Anything after a # is a comment and is ignored. Comments are useful for leaving notes in your playbooks, both for yourself and for anyone else reading them later.

# Management IP of the device
mgmt_ip: 192.168.202.101

Key-Value Pairs and Lists

Almost everything you write in YAML is built from two things, key-value pairs and lists. Once you understand these two, you'll be able to read most Ansible files without any trouble.

Key-Value Pairs

A key-value pair is simply a key, followed by a colon, and then a value. You've already seen plenty of these.

hostname: r1
mgmt_ip: 192.168.202.101

Here, hostname and mgmt_ip are the keys, and r1 and 192.168.202.101 are the values. A group of key-value pairs like this is called a dictionary. You might also see it called a mapping or a hash, but they all mean the same thing.

The value doesn't have to be a single item. It can also be another dictionary. In that case, you leave the value empty after the colon and indent the next lines.

interface:
  name: Ethernet0/1
  description: uplink to r2
  ip: 10.1.1.1

Here, the value of interface is another dictionary that contains name, description, and ip. This is called nesting, and you'll see it everywhere in Ansible.

Lists

A list is a collection of items, where each item starts with a dash and a space.

ntp_servers:
  - 10.10.10.10
  - 10.10.10.11

Here, ntp_servers is a key, and its value is a list with two items.

Each item in a list can also be a dictionary. This is very common in networking because most things we configure have more than one setting. For example, a list of VLANs where each VLAN has an ID and a name.

vlans:
  - id: 10
    name: users
  - id: 20
    name: voice

Each dash starts a new item, and the lines below it, indented to the same level, belong to that item. So we have two VLANs, each with its own id and name.

In Python, this YAML becomes a dictionary with one key, vlans, whose value is a list of dictionaries.

{
    "vlans": [
        {"id": 10, "name": "users"},
        {"id": 20, "name": "voice"}
    ]
}

The YAML key-value pairs become Python dictionaries ({}), and the YAML lists become Python lists ([]). Also note that 10 and 20 become integers, while users and voice become strings.

Putting It Together

Let's look at the inventory file from the previous chapter again with this in mind.

---
all:
  hosts:
    r1:
      ansible_host: 192.168.202.101
      ansible_network_os: cisco.ios.ios
      ansible_connection: ansible.netcommon.network_cli
      ansible_user: admin
      ansible_password: admin

This entire file is just nested dictionaries. all is a key, and its value is a dictionary containing hosts. The value of hosts is another dictionary containing r1. And the value of r1 is a dictionary of key-value pairs that tell Ansible how to connect to the device.

If we added another device, it would go under hosts at the same level as r1.

---
all:
  hosts:
    r1:
      ansible_host: 192.168.202.101
      ...
    r2:
      ansible_host: 192.168.202.102
      ...

Playbook Structure

Before we go through our playbook line by line, it helps to understand how a playbook is structured. There are three main parts, and they fit inside each other.

  • Playbook - The YAML file itself. A playbook contains one or more plays.
  • Play - Defines which devices to run against and what tasks to run on them.
  • Task - A single action, such as running a show command or pushing a config. Each task uses a module to do its job.

Here's our playbook from the previous chapter again.

---
- name: Quick test
  hosts: all
  gather_facts: false

  tasks:
    - name: Run show version
      cisco.ios.ios_command:
        commands:
          - show version
      register: output

    - name: Display output
      ansible.builtin.debug:
        var: output.stdout_lines

If we break it down into these three parts, it looks like this.

show_version.yml          <- Playbook
└── Quick test            <- Play
    ├── Run show version  <- Task
    └── Display output    <- Task

You might have noticed that the playbook starts with a dash, - name: Quick test. That's because a playbook is actually a list of plays. In our case, the list only has one play, but you can have as many as you like. For example, you could have one play that runs against your Cisco devices and another that runs against your Arista devices, all in the same playbook.

The same applies to tasks. tasks is a list, and each task starts with a dash. Ansible runs the tasks in order, from top to bottom, on every device in the play. The second task doesn't start until the first one has finished.

The second task, Display output, uses the ansible.builtin.debug module. This module doesn't connect to the device or change anything. It simply prints something to the screen, which makes it very useful for checking what's stored in a variable.

In the first task, we used register: output to save the result of show version into a variable called output. This variable contains more than just the command output. It also includes other information, such as whether the task changed anything or failed. If you print the whole variable, you'll see a lot of extra data.

ok: [r1] => {
    "output": {
        "changed": false,
        "failed": false,
        "stdout": [
            "Cisco IOS Software [Dublin], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)"
        ],
        "stdout_lines": [
            [
                "Cisco IOS Software [Dublin], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)"
            ]
        ]
    }
}

That's why we use var: output.stdout_lines. The dot lets us pick a specific part of the output variable, and stdout_lines contains just the command output, split into separate lines. There's also stdout, which contains the same output as one long string, but stdout_lines is much easier to read on screen.

If you want to see everything that's stored in the variable, try changing it to var: output and running the playbook again.

Modules and Collections

Let's go back to the inventory for a moment and look at these two lines.

ansible_network_os: cisco.ios.ios
ansible_connection: ansible.netcommon.network_cli

You might be wondering how Ansible knows what a Cisco device is, or how to connect to it and run commands. There are hundreds of network vendors out there, and each one has its own CLI, prompts, and quirks. So, does all of this come built into Ansible? Not exactly.

What Is a Collection?

When you install ansible-core, you only get the main Ansible engine and a small set of built-in modules, such as ansible.builtin.debug. It doesn't know anything about Cisco, Arista, Juniper, or any other vendor.

Support for each vendor comes from collections. A collection is a package that contains everything needed to work with a specific platform, such as modules, plugins, and documentation. Collections are usually maintained by the vendors themselves, Red Hat, or the community, and they're released separately from Ansible. This means a vendor can add new features or fix bugs without waiting for a new version of Ansible.

For example, the cisco.ios collection contains all the modules and plugins needed to manage Cisco IOS and IOS-XE devices.

When we installed the ansible package in the previous chapter, it came with a large number of collections already included, which is why our playbook worked without installing anything else. cisco.ios and ansible.netcommon are both part of it.

When you run ansible-galaxy collection list, you'll see every collection installed on your machine, along with its version and location. The full list is quite long, so here's a trimmed version of mine.

ansible-galaxy collection list

# /Users/suresh/.ansible/collections/ansible_collections
Collection                               Version
---------------------------------------- -------
cisco.asa                                6.1.0
paloaltonetworks.panos                   2.21.4

# /Users/suresh/ansible_v1/venv/lib/python3.12/site-packages/ansible_collections
Collection                               Version
---------------------------------------- -------
ansible.netcommon                        8.6.2
ansible.utils                            6.1.0
arista.eos                               12.2.0
cisco.ios                                11.5.1
cisco.iosxr                              12.4.2
cisco.nxos                               11.2.0
fortinet.fortios                         2.6.0
junipernetworks.junos                    ...
...

The collections are grouped by location. The second group is inside the virtual environment, and these came with the ansible package. The first group is in my home directory, and these are collections I installed separately using ansible-galaxy at some point.

Fully Qualified Collection Names

Every module and plugin in Ansible has a full name, made up of three parts. This is called the Fully Qualified Collection Name, or FQCN.

namespace.collection.name
  • namespace - Usually the vendor or organisation, such as cisco or arista
  • collection - The collection within that namespace, such as ios or eos
  • name - The module or plugin itself

So, if we look at the names we've used so far:

  • cisco.ios.ios - The ios plugin from the cisco.ios collection. It tells Ansible how to deal with a Cisco IOS device, such as what the prompts look like and how to enter config mode.
  • ansible.netcommon.network_cli - The network_cli connection plugin from the ansible.netcommon collection. This collection is shared by most network vendors and manages the SSH connection itself.
  • cisco.ios.ios_command - The ios_command module from the cisco.ios collection, which we used to run show version.
  • ansible.builtin.debug - The debug module from ansible.builtin, which is part of ansible-core itself.

In other words, network_cli manages the connection, and cisco.ios.ios tells it how to talk to a Cisco device once it's connected.

You might see older examples online that just use ios_command instead of cisco.ios.ios_command. This still works in many cases, but I recommend always using the full name. It makes it clear exactly which collection the module comes from and avoids confusion if two collections have a module with the same name.

What Is Ansible Galaxy?

Ansible Galaxy is a public hub where collections are published and shared. When you need a collection that isn't already installed, you download it from Galaxy using the ansible-galaxy command, which comes with Ansible.

To see which collections are already installed, run:

ansible-galaxy collection list

To install a collection, run:

ansible-galaxy collection install cisco.ios

Since cisco.ios already comes with the ansible package, you'll see a message saying there's nothing to do. That's perfectly fine and simply confirms the collection is already installed.

ansible-galaxy collection install cisco.ios
Starting galaxy collection install process
Nothing to do. All requested collections are already installed. If you want to reinstall them, consider using `--force`.

Running Multiple Commands

So far, our playbook only runs a single command. The commands option is a list, so running more than one command is as simple as adding another item to the list. Let's create a new playbook called show_ver_bgp.yml and add show ip bgp summary as the second command.

---
- name: Quick test
  hosts: all
  gather_facts: false

  tasks:
    - name: Run show commands
      cisco.ios.ios_command:
        commands:
          - show version | incl RELEASE
          - show ip bgp summary
      register: output

    - name: Display output
      ansible.builtin.debug:
        var: output.stdout_lines

Ansible runs the commands in order, and the output of both is saved into the same output variable.

ansible-playbook show_ver_bgp.yml

PLAY [Quick test] *************************************

TASK [Run show commands] ******************************
ok: [r1]

TASK [Display output] *********************************
ok: [r1] => {
  "output.stdout_lines": [
    [
      "Cisco IOS Software [Dublin], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)"
    ],
    [
      "BGP router identifier 10.0.0.1, local AS number 100",
      "BGP table version is 7, main routing table version 7",
      "6 network entries using 1488 bytes of memory",
      "6 path entries using 816 bytes of memory",
      "4/4 BGP path/bestpath attribute entries using 1184 bytes of memory",
      "2 BGP AS-PATH entries using 64 bytes of memory",
      "0 BGP route-map cache entries using 0 bytes of memory",
      "0 BGP filter-list cache entries using 0 bytes of memory",
      "BGP using 3552 total bytes of memory",
      "BGP activity 6/0 prefixes, 6/0 paths, scan interval 5 secs",
      "6 networks peaked at 11:43:25 Oct 2 2026 UTC (01:23:54.634 ago)",
      "",
      "Neighbor        V           AS MsgRcvd MsgSent   TblVer  InQ OutQ Up/Down  State/PfxRcd",
      "172.16.1.1      4          200      99      97        7    0    0 01:23:59        5"
    ]
  ]
}

PLAY RECAP ********************************************
r1  : ok=2  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Remember the list inside a list we saw earlier? This is where it starts to make sense. stdout_lines is a list, and each command gets its own item in that list. The first item is the output of show version | incl RELEASE, and the second item is the output of show ip bgp summary.

If you only want to see the output of one command, you can pick it by its position in the list. Lists start counting from 0, so the first command is 0 and the second command is 1.

    - name: Display BGP summary
      ansible.builtin.debug:
        var: output.stdout_lines[1]

This prints only the output of show ip bgp summary.

Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.