Skip to main content
RunBook Academy

AnsibleXXIII · Tags, Blocks and Error HandlingError handling

Blocks and shared directives

Intermediate⏱ ~18 minansible-playbook

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

Not yet marked complete on this device.

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:

Read-only / Safea block whose when is false
$ ansible-playbook -i inventory.ini blockdir.yml
TASK [not run] *****************************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

That 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:

Read-only / Safea failure inside a block with ignore_errors
$ ansible-playbook -i inventory.ini blockdir.yml
TASK [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=1

inner 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 blockrescue: on the block
Remaining block tasks after a failureRunSkipped
Recap counterignoredrescued
Somewhere to put a compensating actionNoYes
Play continues afterwardsYesYes, 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:

Read-only / Safeloop on a block
$ 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 7

The 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

  1. Q1. A block carries when: false and contains four tasks. What does the run output show?

  2. 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.

  3. Q3. Which directives can be written at block level and applied to every task inside? Select all that apply.

  4. 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.