AnsibleXXIII · Tags, Blocks and Error HandlingError handling
Blocks and shared directives
What you'll learn
- Group tasks under a shared directive without repeating it per task
- Predict how a block-level when is reported in the run output
- State what a block cannot do, and what to use instead
- Use a block to express transactional intent before adding rescue
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
A block is a list of tasks that share directives:
- name: configure the web tier
block:
- name: install nginx
ansible.builtin.package:
name: nginx
state: present
- name: write the site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
mode: '0644'
- name: enable and start nginx
ansible.builtin.systemd_service:
name: nginx
state: started
enabled: true
when: webapp_manage_web_tier | bool
become: true
tags: [webtier]
Three tasks, one when, one become, one tag. Written per task that is nine
directives; written on the block it is three, and — this is the part that
matters — they cannot drift apart. The commonest cause of a half-applied
change is a when copied onto four tasks out of five.
The documentation states the scope plainly: most of what you can apply to a single task, with the exception of loops, can be applied at the block level.
What a block-level directive actually does
It is distributed to each task, not evaluated once for the group. The run
output shows this clearly — a block skipped by its when reports one skip per
task, not one skip for the block:
$ ansible-playbook -i inventory.ini blockdir.ymlTASK [not run] *****************************************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0That per-task distribution has a consequence people do not expect. A
block-level when referencing something an early task in the block changes
is re-evaluated for each subsequent task, against the state at that moment.
- name: this is not a transaction
block:
- name: stop the service
ansible.builtin.systemd_service:
name: webapp
state: stopped
- name: this task re-evaluates the condition
ansible.builtin.command: /usr/local/bin/migrate
changed_when: false
when: webapp_service_running | bool
If webapp_service_running is a fact refreshed between tasks, the second task
may evaluate a different answer from the first. Usually it is a static
variable and nothing happens. When it is not, the symptom is a block that ran
partly.
ignore_errors on a block
ansible-doc -t keyword ignore_errors lists Play, Role, Block, Task
and Handler. On a block it applies to every task inside, and — importantly
— it does not stop the block:
$ ansible-playbook -i inventory.ini blockdir.ymlTASK [inner one] ***************************************************************
ok: [localhost]
TASK [inner two fails] *********************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "boom"}
...ignoring
TASK [inner three] *************************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=1inner three ran. Compare that with the rescue behaviour in lesson 5,
where a failure abandons the rest of the block and jumps to the rescue. The
two look similar in a playbook and behave oppositely:
ignore_errors: true on the block | rescue: on the block | |
|---|---|---|
| Remaining block tasks after a failure | Run | Skipped |
| Recap counter | ignored | rescued |
| Somewhere to put a compensating action | No | Yes |
| Play continues afterwards | Yes | Yes, if the rescue succeeds |
Lesson 7 argues that blanket ignore_errors is almost always the wrong
choice. The point here is narrower: even where it is right, it is not a
grouping mechanism for “these tasks are optional as a unit”, because the unit
does not stop.
What a block cannot do
A block cannot be looped. This is a hard parse error, not a warning:
$ ansible-playbook -i inventory.ini blockloop.yml --syntax-check[ERROR]: 'loop' is not a valid attribute for a Block
Origin: /srv/automation/blockloop.yml:7:7
5 block:
6 - ansible.builtin.debug: {msg: "{{ item }}"}
7 loop: [a, b]
^ column 7The workaround is include_tasks with a loop, which puts the tasks in a
separate file and loops the include:
- name: configure each virtual host
ansible.builtin.include_tasks: configure-vhost.yml
loop: "{{ webapp_vhosts }}"
loop_control:
loop_var: vhost
That is a real trade and Part XXII lesson 5 priced it: you get the loop and
you lose the dry run, because the included file is invisible to
--list-tasks. If the loop is over a handful of static entries, unrolling
them explicitly is often the better bargain.
Blocks as transactional intent
The most useful thing a block does is not mechanical. It marks a set of tasks as belonging together, which is the prerequisite for saying what should happen if one of them fails.
- name: swap in the new configuration
block:
- name: back up the current config
ansible.builtin.copy:
src: /etc/webapp/app.conf
dest: /etc/webapp/app.conf.prev
remote_src: true
mode: '0640'
- name: write the new config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/webapp/app.conf
mode: '0640'
notify: webapp restart webapp
- name: apply it now rather than at the end of the play
ansible.builtin.meta: flush_handlers
- name: confirm the service came back
ansible.builtin.uri:
url: "http://127.0.0.1:{{ webapp_listen_port }}/healthz"
status_code: 200
retries: 6
delay: 5
Four tasks that only make sense together: back up, replace, apply, verify.
Written as a block, the group is now something you can attach a rescue to —
restore app.conf.prev, restart, verify — which is lesson 5.
Written as four loose tasks, there is nowhere to put that. The block is what turns “some tasks” into “a change that either lands or is backed out”.
Knowledge check
Knowledge check · 4 questions
Q1. A block carries when: false and contains four tasks. What does the run output show?
Q2. A block creates no variable scope and no transaction, so a set_fact inside it is visible afterwards and a file written by a task that later fails stays written.
Q3. Which directives can be written at block level and applied to every task inside? Select all that apply.
Q4. A task in a block fails. The block carries ignore_errors: true. What happens to the remaining tasks in the block?
Passing score: 75%. Answers are checked in this browser.