Skip to main content
RunBook Academy

AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution

run_once means once per batch

Advanced⏱ ~28 minansible-coreansible-doc

What you'll learn

  • State what run_once actually guarantees, in the words of the keyword definition
  • Predict how many times a run_once task executes under serial, and verify it
  • Explain which host runs it when the expected host has already failed
  • Recognise the failure modes: a conditional skipped on one host, and a failure applied to a whole batch

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.

Almost everyone who uses run_once believes it means “run this task one time for this play”. It does not, and the difference only appears once you add serial — which you add later, for safety, in a change that looks entirely unrelated.

This lesson is short on opinion and long on evidence, because this is a semantic people state confidently and get wrong, and anything that mutates shared state depends on it.

What the keyword actually says

Not from memory, and not from a blog post. From the installed controller:

Read-only / Safethe definition on your own controller
$ ansible-doc -t keyword run_once
run_once:
applies_to:
- Play
- Role
- Block
- Task
- Handler
description: Boolean that will bypass the host loop, forcing the task to attempt
  to execute on the first host available and afterward apply any results and facts
  to all active hosts in the same batch.
priority: 0
template: explicit
type: bool

Three phrases in that sentence carry the whole behaviour, and each one is a separate surprise:

  1. “the first host available” — not a host you chose, and not necessarily the same host on the next run.
  2. “all active hosts in the same batch” — batch, not play. If you have serial, you have batches.
  3. “apply any results and facts” — the outcome of the task, including a failure, is written onto every other host in that batch.

Once per batch, demonstrated

Six hosts, serial: 2, one task marked run_once:

- name: run_once under serial
  hosts: web
  gather_facts: false
  serial: 2
  tasks:
    - name: This claims to run once
      ansible.builtin.debug:
        msg: "RUNONCE executed on {{ inventory_hostname }} | batch={{ ansible_play_batch }} | all={{ ansible_play_hosts_all }}"
      run_once: true
Read-only / Safethree executions, not one
$ ansible-playbook -i hosts.ini runonce.yml
PLAY [run_once under serial] ***************************************************

TASK [This claims to run once] *************************************************
ok: [web1] => {
  "msg": "RUNONCE executed on web1 | batch=['web1', 'web2'] | all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
}

PLAY [run_once under serial] ***************************************************

TASK [This claims to run once] *************************************************
ok: [web3] => {
  "msg": "RUNONCE executed on web3 | batch=['web3', 'web4'] | all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
}

PLAY [run_once under serial] ***************************************************

TASK [This claims to run once] *************************************************
ok: [web5] => {
  "msg": "RUNONCE executed on web5 | batch=['web5', 'web6'] | all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
}

PLAY RECAP *********************************************************************
web1                       : ok=1    changed=0    unreachable=0    failed=0
web3                       : ok=1    changed=0    unreachable=0    failed=0
web5                       : ok=1    changed=0    unreachable=0    failed=0

Three executions. On web1, web3 and web5 — the first host of each batch.

The two special variables in that output are the tools for reasoning about it, and they behave differently:

  • ansible_play_batch is the current batch. It changed on every iteration.
  • ansible_play_hosts_all is the whole play’s host list. It was identical in all three, unaffected by batching.

That second variable is what lesson 6 builds the fix from.

“The first host available” is not a fixed host

The second phrase in the definition has its own consequences. run_once does not pick a host and stick with it — it takes whichever host is first in the current batch and still active.

Six hosts, no serial, with web1 failing in an earlier task:

Read-only / Safethe first host is gone, so the next one runs it
$ ansible-playbook -i hosts.ini runonce-hostgone.yml
TASK [web1 drops out here] *****************************************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "web1 is out"}
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]

TASK [Which host does run_once pick now] ***************************************
ok: [web2] => {
  "msg": "RUNONCE on web2"
}

The task ran on web2. That is correct behaviour and it is exactly what “first host available” means — but it has an operational edge that is easy to miss.

If the run_once task depends on something about the host it runs on — a mounted filesystem, a database client configuration, a firewall rule permitting egress, a credential in a local file — then it worked on web1 during testing and will now attempt to run on whichever host happens to survive. Two runs of the same playbook can execute the same task on two different machines with no change to the playbook at all.

The fix is not to make every host identical. It is to stop letting the scheduler choose: name the host with delegate_to, so the task runs where you decided it should.

A when: on a run_once task is evaluated once

This is the sharpest edge in the lesson, because the failure is total silence.

run_once bypasses the host loop before the conditional is evaluated. The when: is therefore tested against one host only — the first available one — and its result decides the task for the entire batch.

- name: Only web4 satisfies the condition
  ansible.builtin.debug:
    msg: "RUNONCE+WHEN on {{ inventory_hostname }}"
  run_once: true
  when: inventory_hostname == 'web4'
Read-only / Safethe task that never runs anywhere
$ ansible-playbook -i hosts.ini runonce-when.yml
PLAY [run_once combined with when that excludes the first host] ****************

TASK [Only web4 satisfies the condition] ***************************************
skipping: [web1]

TASK [Marker] ******************************************************************
ok: [web1] => {
  "msg": "play continued"
}

PLAY RECAP *********************************************************************
web1                       : ok=1    changed=0    unreachable=0    failed=0    skipped=1

The condition was evaluated on web1, was false, and the task was skipped. It never ran on web4. There is no warning, no error, and the recap shows a clean run with one skipped task — which is what a deliberately skipped task also looks like.

When the run_once task fails

The third phrase — “apply any results and facts to all active hosts in the same batch” — includes failure results.

- name: Fails on the one host it runs on
  ansible.builtin.fail:
    msg: "migration failed on {{ inventory_hostname }}"
  run_once: true

- name: Would anyone reach this
  ansible.builtin.debug:
    msg: "reached on {{ inventory_hostname }}"
Read-only / Safeone host fails, nobody continues
$ ansible-playbook -i hosts.ini runonce-fail.yml
TASK [Fails on the one host it runs on] ****************************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "migration failed on web1"}

PLAY RECAP *********************************************************************
web1                       : ok=0    changed=0    unreachable=0    failed=1

The second task did not execute on any host. The recap names only web1, because the other five never produced a result of their own.

This is the behaviour you want. A run_once task is doing shared work — a migration, a schema change, a global switch — and if it failed, no host should proceed as though it succeeded. But note what it means for the recap: the host named in the failure is an implementation detail. It is whichever host the scheduler picked, and it may have nothing to do with where the problem is. Under serial, only that batch stops; later batches run and try the shared work again.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play over 6 hosts has serial: 2 and one task marked run_once. How many times does that task execute?

  2. Q2. A task has run_once: true and when: inventory_hostname == 'web4'. The play targets web1 through web6 with no serial. What happens?

  3. Q3. Which of these follow from the run_once keyword definition? Select all that apply.

  4. Q4. Adding delegate_to to a run_once task makes it run exactly once for the whole play.

Passing score: 75%. Answers are checked in this browser.