AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
run_once means once per batch
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
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:
$ ansible-doc -t keyword run_oncerun_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: boolThree phrases in that sentence carry the whole behaviour, and each one is a separate surprise:
- “the first host available” — not a host you chose, and not necessarily the same host on the next run.
- “all active hosts in the same batch” — batch, not play. If you have
serial, you have batches. - “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
$ ansible-playbook -i hosts.ini runonce.ymlPLAY [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=0Three 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_batchis the current batch. It changed on every iteration.ansible_play_hosts_allis 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:
$ ansible-playbook -i hosts.ini runonce-hostgone.ymlTASK [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'
$ ansible-playbook -i hosts.ini runonce-when.ymlPLAY [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=1The 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 }}"
$ ansible-playbook -i hosts.ini runonce-fail.ymlTASK [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=1The 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
Q1. A play over 6 hosts has serial: 2 and one task marked run_once. How many times does that task execute?
Q2. A task has run_once: true and when: inventory_hostname == 'web4'. The play targets web1 through web6 with no serial. What happens?
Q3. Which of these follow from the run_once keyword definition? Select all that apply.
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.