Ansible playbooks, tasks, handlers and conditionals
In short: A playbook is a YAML file of plays, each targeting a group of hosts with an ordered list of tasks. Conditionals, loops and registered results make tasks smart, and handlers run follow-up actions such as saving a config only when something changed.
Plays and tasks
A playbook is a YAML file containing one or more plays. Each play says which hosts to target and lists tasks to run in order. Each task calls one module with parameters and should have a clear name, because the name is what you see in the output and logs.
# ntp.yml
- name: Configure NTP on IOS switches
hosts: switches
gather_facts: false
tasks:
- name: Gather IOS facts
cisco.ios.ios_facts:
gather_subset: min
- name: Show the software version
ansible.builtin.debug:
msg: "{{ inventory_hostname }} runs {{ ansible_net_version }}"
- name: Ensure NTP servers are configured
cisco.ios.ios_ntp_global:
config:
servers:
- server: "{{ item }}"
vrf: MGMT
state: merged
loop: "{{ ntp_servers }}"
notify: Save config
handlers:
- name: Save config
cisco.ios.ios_config:
save_when: modified
Run it with ansible-playbook -i inventory.yml ntp.yml. Variables such as ntp_servers come from the inventory, and {{ ... }} is Jinja2 syntax for inserting a value.
Handlers
A handler is a task that only runs when another task notifies it and that task reported changed. Even if ten tasks notify the same handler, it runs once, at the end of the play. Classic uses are saving a switch configuration, restarting a service after its config file changes, or reloading a firewall policy.
Conditionals, loops and registered results
- name: Check interface status
cisco.ios.ios_command:
commands: show interfaces status | include notconnect
register: notconnect
- name: Warn when many ports are unused
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has {{ notconnect.stdout_lines[0] | length }} unused ports"
when: notconnect.stdout_lines[0] | length > 20
- name: Install monitoring packages on Debian hosts only
ansible.builtin.apt:
name: [snmpd, chrony]
state: present
when: ansible_facts['os_family'] == "Debian"
registersaves a task's result into a variable for later tasks.whentakes a condition written without{{ }}; the task is skipped if it is false.looprepeats a task for each item in a list, available asitem.
Running playbooks safely
| Option or keyword | Use |
|---|---|
--check --diff | Preview changes and show differences without applying them (where modules support it) |
--limit acc-sw1 | Run against one host or a subset first |
tags and --tags ntp | Run only part of a large playbook |
serial: 5 | Change hosts in batches rather than all at once |
block / rescue / always | Handle errors and run clean-up steps |
max_fail_percentage | Stop the run if too many hosts fail |
Using serial together with max_fail_percentage gives you a rolling change: the first batch acts as a canary, and if it fails the playbook stops before touching the rest of the network. This mirrors how you would make a risky change by hand.
Tip: YAML is strict about indentation. Use two spaces, never tabs, and run ansible-playbook --syntax-check or ansible-lint before every run.
Practice
When does a handler run?
How do you run a playbook against a single switch first?
--limit with that host name, ideally together with --check --diff.What does register do?
Key takeaways
- Playbooks contain plays; plays target hosts and run named tasks in order.
- Handlers run once, at the end, only if notified by a changed task.
- Use register, when and loop to make tasks react to data.
- Preview with --check --diff and limit scope with --limit and serial.
- Lint and syntax-check YAML before running.
Original content written by Mohamed Fayas for FM TecHub. © 2026 FM TecHub. You're welcome to share a link to this page; please don't copy the text elsewhere without permission.