Ansible Ad-hoc Commands, Facts, and Registering Output

Ansible Ad-hoc Commands, Facts, and Registering Output
Table of Contents

So far, most of what we've done has been about making changes, such as creating VLANs and saving the config. But a lot of day-to-day network work can be also about getting information from devices such as checking software versions, finding serial numbers, looking at interface status, or just running a quick show command across a group of switches.

In this post, we'll look at three ways to do that. First, we'll cover ad-hoc commands, which let you run a single task against your devices straight from the command line, without writing a playbook. Then, we'll look at facts, which is how Ansible collects information about a device, such as its hostname, model, and software version. You may have noticed that we've been setting gather_facts: false in every playbook so far, and we'll finally see why.

Finally, we'll go back to register and debug, which we briefly used in our very first playbook, and look at how to save the output of a task and use it in a more useful way.

Ad-hoc Commands

So far, every time we wanted Ansible to do something, we wrote a playbook. But sometimes you just want to run a quick task, like checking the VLANs on a group of switches, and writing a playbook for that may feels like overkill. This is where ad-hoc commands can help.

An ad-hoc command lets you run a single module against your devices straight from the command line, without writing a playbook. It uses the same inventory, group_vars, and host_vars as your playbooks, so everything we've set up so far still applies.

The Syntax

Ad-hoc commands use the ansible command, rather than ansible-playbook. The basic syntax looks like this.

ansible <pattern> -m <module> -a <arguments>
  • pattern - Which devices to run against. This works exactly like hosts in a playbook, so you can use a device name, a group, or any of the host patterns we covered earlier.
  • -m - The module to run, such as cisco.ios.ios_command.
  • -a - The arguments for the module. These are the same options you'd normally put under the module in a playbook.

Running a Show Command

Let's start by running show vlan brief on core1.

ansible core1 -m cisco.ios.ios_command -a "commands='show vlan brief'"
core1 | SUCCESS => {
  "changed": false,
  "stdout": [
    "..."
  ],
  "stdout_lines": [
    [
      "VLAN Name                             Status    Ports",
      "---- -------------------------------- --------- ---------------",
      "1    default                          active    Et1/1, Et1/2, Et1/3",
      "10   users                            active    ",
      "20   voice                            active    ",
      "30   printers                         act/lshut ",
      "100  servers                          active    ",
      "1002 fddi-default                     act/unsup ",
      "1003 token-ring-default               act/unsup ",
      "1004 fddinet-default                  act/unsup ",
      "1005 trnet-default                    act/unsup"
    ]
  ]
}

If you compare this with a playbook, -m cisco.ios.ios_command is the module, and -a "commands='show vlan brief'" is the same as setting commands under the module. The arguments are written in key=value format, and since the whole thing contains spaces, it's wrapped in quotes.

The output also looks a bit different from what we're used to with playbooks. Instead of tasks and a PLAY RECAP, ad-hoc commands show the device name, the result (SUCCESS in this case), and everything the module returned. You'll recognise stdout and stdout_lines from our very first playbook. I've shortened stdout here, since it contains the same output as one long line.

Running Against a Group

Since the pattern works just like hosts, we can run the same command against a whole group.

ansible access -m cisco.ios.ios_command -a "commands='show vlan brief'"
access3 | SUCCESS => {
  "changed": false,
  "stdout": [
    "..."
  ],
  "stdout_lines": [
    [
      "VLAN Name                             Status    Ports",
      "---- -------------------------------- --------- ---------------",
      "1    default                          active    Et0/3",
      "10   users                            active    ",
      "20   voice                            active    ",
      "30   printers                         active    ",
      "1002 fddi-default                     act/unsup ",
      "1003 token-ring-default               act/unsup ",
      "1004 fddinet-default                  act/unsup ",
      "1005 trnet-default                    act/unsup"
    ]
  ]
}
access1 | SUCCESS => {
  "changed": false,
  "stdout": [
    "..."
  ],
  "stdout_lines": [
    [
      "VLAN Name                             Status    Ports",
      "---- -------------------------------- --------- ---------------",
      "1    default                          active    Et0/3",
      "10   users                            active    ",
      "20   voice                            active    ",
      "30   printers                         active    ",
      "50   lab                              active    ",
      "1002 fddi-default                     act/unsup ",
      "1003 token-ring-default               act/unsup ",
      "1004 fddinet-default                  act/unsup ",
      "1005 trnet-default                    act/unsup"
    ]
  ]
}
access2 | SUCCESS => {
  ...
}

We get the output from all three access switches. You can also see the lab VLAN we added to access1 using host_vars in the previous post. This is a really quick way to check something across a group of devices.

Running Multiple Commands

The key=value format works fine for a single command, but commands is a list, and passing a list this way gets awkward. Instead, you can pass the arguments in JSON format.

ansible core1 -m cisco.ios.ios_command -a '{"commands": ["show version | incl RELEASE", "show clock"]}'
core1 | SUCCESS => {
  "changed": false,
  "stdout": [
    "..."
  ],
  "stdout_lines": [
    [
      "Cisco IOS Software [Dublin], Linux Software (X86_64BI_LINUX_L2-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)"
    ],
    [
      "*11:48:23.258 UTC Sat Oct 3 2026"
    ]
  ]
}

The JSON here is the same structure we'd write in YAML, just in a different format. commands is a list with two items, and each command gets its own item in stdout_lines. Notice that the JSON is wrapped in single quotes, so the double quotes inside it don't confuse the shell.

Making Changes

Ad-hoc commands aren't only for show commands. You can use any module, including ones that make changes. For example, let's create VLAN 60 on access1 using ios_vlans.

ansible access1 -m cisco.ios.ios_vlans -a '{"config": [{"vlan_id": 60, "name": "test"}]}'
access1 | CHANGED => {
  "after": [
    ...
    {
      "mtu": 1500,
      "name": "test",
      "shutdown": "disabled",
      "state": "active",
      "vlan_id": 60
    },
    ...
  ],
  "before": [
    ...
  ],
  "changed": true,
  "commands": [
    "vlan 60",
    "name test"
  ]
}

This time, the result is CHANGED, and we can see the commands Ansible sent to the device, vlan 60 and name test. I've shortened before and after, since they list every VLAN on the switch. If you run the same command again, you'll see SUCCESS instead, since the VLAN already exists.

ansible access1 -m cisco.ios.ios_vlans -a '{"config": [{"vlan_id": 60, "name": "test"}]}'

access1 | SUCCESS => {
  "before": [
    ...
    {
      "mtu": 1500,
      "name": "test",
      "shutdown": "disabled",
      "state": "active",
      "vlan_id": 60
    },
    ...
  ],
  "changed": false,
  "commands": []
}

Notice that commands is now empty, since there was nothing to send to the device.

When to Use Ad-hoc Commands

Ad-hoc commands are great for quick, one-off tasks, such as checking something across a group of devices or testing a module before using it in a playbook.

For anything you'll need to run more than once, or anything that makes changes to your network, I recommend using a playbook instead. Playbooks can be saved, reviewed, and stored in Git, so you always have a record of what was run. An ad-hoc command that makes a change is easy to forget about, and there's no record of it unless you go looking through your shell history.

What Are Facts?

Facts are pieces of information that Ansible collects about a device, such as its hostname, software version, serial number, and interfaces. Once collected, they're stored as variables, so you can use them in your playbooks just like any other variable.

You may have noticed that every playbook we've written so far has gather_facts: false. By default, Ansible gathers facts at the start of every play, before running any tasks. Since we didn't need any of that information, we turned it off. Gathering facts takes a bit of time and sends extra commands to every device, so there's no point doing it if you're not going to use them.

Gathering Facts with ios_facts

For Cisco IOS devices, facts are collected using the cisco.ios.ios_facts module. Let's start by running it as an ad-hoc command against core1.

ansible core1 -m cisco.ios.ios_facts
core1 | SUCCESS => {
  "ansible_facts": {
    "ansible_net_api": "cliconf",
    "ansible_net_gather_network_resources": [],
    "ansible_net_gather_subset": [
      "default"
    ],
    "ansible_net_hostname": "core1",
    "ansible_net_image": "unix:/iol/iol.bin",
    "ansible_net_iostype": "IOS",
    "ansible_net_operatingmode": "autonomous",
    "ansible_net_python_version": "3.12.10",
    "ansible_net_serialnum": "4",
    "ansible_net_system": "ios",
    "ansible_net_version": "17.12.1",
    "ansible_network_resources": {}
  },
  "changed": false
}

Behind the scenes, the module runs a few show commands on the device, such as show version, and turns the output into structured data. Instead of having to read through show version yourself, you get the hostname, version, image, and serial number as separate values. On a physical switch, you'd also see facts like the model number, but IOL doesn't provide that.

Gathering More Facts

By default, ios_facts only collects a small set of facts, which you can see under ansible_net_gather_subset. If you need more, you can use the gather_subset option. For example, let's collect facts about the interfaces as well.

ansible core1 -m cisco.ios.ios_facts -a "gather_subset=interfaces"
core1 | SUCCESS => {
  "ansible_facts": {
    "ansible_net_all_ipv4_addresses": [
      "192.168.202.101",
      "172.16.1.1",
      "172.16.1.3",
      "172.16.1.5",
      "172.16.1.12",
      "10.0.0.1"
    ],
    "ansible_net_all_ipv6_addresses": [],
    "ansible_net_gather_subset": [
      "default",
      "interfaces"
    ],
    "ansible_net_hostname": "core1",
    "ansible_net_interfaces": {
      "Ethernet0/1": {
        "bandwidth": 10000,
        "description": "core1 -> access1",
        "duplex": null,
        "ipv4": [
          {
            "address": "172.16.1.1",
            "subnet": "31"
          }
        ],
        "lineprotocol": "up",
        "macaddress": "caf0.0001.0001",
        "mediatype": "10/100/1000BaseTX",
        "mtu": 1500,
        "operstatus": "up",
        "type": "Ethernet"
      },
      ...
    },
    "ansible_net_neighbors": {
      "Ethernet0/1": [
        {
          "host": "access1.lab",
          "ip": "172.16.1.0",
          "platform": "Linux Unix",
          "port": "Ethernet0/1"
        }
      ],
      ...
    },
    ...
  },
  "changed": false
}

I've shortened the output quite a bit, since it lists every interface and neighbor on the switch. On top of the default facts, we now have three new ones.

  • ansible_net_all_ipv4_addresses - Every IPv4 address configured on the device
  • ansible_net_interfaces - Each interface with its description, IP address, status, MTU, and MAC address
  • ansible_net_neighbors - The CDP or LLDP neighbors on each interface, which is basically show cdp neighbors as structured data

You can also gather other subsets, such as hardware and config, or use all to collect everything. The documentation for ios_facts lists all the available options.

Using Facts in a Playbook

Collecting facts with an ad-hoc command is useful, but you can also collect them in a playbook. Let's create a new playbook called facts.yml that displays some of the facts for core1.

---
- name: Gather facts
  hosts: core1
  gather_facts: true

  tasks:
    - name: Display facts
      ansible.builtin.debug:
        msg:
          - "Hostname: {{ ansible_net_hostname }}"
          - "Version: {{ ansible_net_version }}"
          - "Image: {{ ansible_net_image }}"
          - "Serial: {{ ansible_net_serialnum }}"

This time, we've set gather_facts: true, and there's no ios_facts task. When gathering facts, Ansible looks at ansible_network_os to work out which facts module to use. Since our switches are set to cisco.ios.ios, Ansible automatically runs cisco.ios.ios_facts for us.

Let's run the playbook.

ansible-playbook facts.yml
PLAY [Gather facts] ***********************************

TASK [Gathering Facts] ********************************
ok: [core1]

TASK [Display facts] **********************************
[WARNING]: Deprecation warnings can be disabled by
setting `deprecation_warnings=False` in ansible.cfg.
[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to
`True` is deprecated, top-level facts will not be auto
injected after the change. This feature will be removed
from ansible-core version 2.24.
Origin: /Users/suresh/.../ansible_v1/facts.yml:10:13

 8       ansible.builtin.debug:
 9         msg:
10           - "Hostname: {{ ansible_net_hostname }}"
               ^ column 13

Use `ansible_facts["fact_name"]` (no `ansible_` prefix) instead.

ok: [core1] => {
  "msg": [
    "Hostname: core1",
    "Version: 17.12.1",
    "Image: unix:/iol/iol.bin",
    "Serial: 4"
  ]
}

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

You'll notice there's a new task at the top called Gathering Facts. This is Ansible collecting the facts before running our tasks. Then, in the Display facts task, we can see the hostname, version, image, and serial number of core1.

The playbook worked, but we also got a deprecation warning. Until now, Ansible has made every fact available as its own variable, such as ansible_net_hostname. This is being phased out, and in a future version of Ansible, facts will only be available under a single variable called ansible_facts. As the warning suggests, we should use ansible_facts['fact_name'] instead, without the ansible_ prefix.

---
- name: Gather facts
  hosts: core1
  gather_facts: true

  tasks:
    - name: Display facts
      ansible.builtin.debug:
        msg:
          - "Hostname: {{ ansible_facts['net_hostname'] }}"
          - "Version: {{ ansible_facts['net_version'] }}"
          - "Image: {{ ansible_facts['net_image'] }}"
          - "Serial: {{ ansible_facts['net_serialnum'] }}"

So ansible_net_hostname becomes ansible_facts['net_hostname'], and so on. Let's run the playbook again.

ansible-playbook facts.yml
PLAY [Gather facts] ***********************************

TASK [Gathering Facts] ********************************
ok: [core1]

TASK [Display facts] **********************************
ok: [core1] => {
  "msg": [
    "Hostname: core1",
    "Version: 17.12.1",
    "Image: unix:/iol/iol.bin",
    "Serial: 4"
  ]
}

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

This time, there's no warning, and we get exactly the same result. You'll still see the old style in a lot of examples online, so it's good to recognise it, but I recommend using ansible_facts in your own playbooks.

One more thing worth mentioning is that gather_facts is true by default. If you remove the line from the playbook completely, Ansible still gathers facts. That's why we've been explicitly setting it to false in our previous playbooks.

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.