AnsibleXXXI · Serial Execution and Failure ToleranceSerial Execution
serial: batching a play
What you'll learn
- Write serial as an integer, a percentage and a list, and predict the resulting batches
- Explain why a percentage resolves against the whole play and rounds down
- Describe what the repeating PLAY banner means for pre_tasks, post_tasks and set_fact
- Choose a canary-then-widen batch shape for a real change
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 play with no serial keyword runs on every targeted host at once.
Not “quickly one after another” — at once, in waves of forks, with no
gap in which anything could be noticed. If the play restarts a service,
every instance of that service goes down together. If the configuration
it deploys is wrong, it is wrong everywhere before the first host has
finished reporting.
This is the default. It is not a setting anyone chose; it is what a reader gets by not thinking about it, and it is almost never what production wants.
serial is the keyword that changes it.
The default, stated plainly
- name: Deploy the application configuration
hosts: webservers
become: true
tasks:
- name: Write the application configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
mode: '0644'
notify: Restart app
handlers:
- name: Restart app
ansible.builtin.systemd_service:
name: app
state: restartedThe blast radius of that play is the entire webservers group, and the
window in which you could stop it is the length of one task.
Adding one line changes the shape of the whole thing:
- name: Deploy the application configuration
hosts: webservers
become: true
serial:
- 1
- 5
- 25%
tasks:
- name: Write the application configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
mode: '0644'
notify: Restart app
handlers:
- name: Restart app
ansible.builtin.systemd_service:
name: app
state: restartedThe three forms
serial accepts an integer, a percentage string, or a list of either.
$ ansible-playbook -i inv6.ini batches.ymlserial: 2
['h01', 'h02']
['h03', 'h04']
['h05', 'h06']
serial: [1, 2, 3]
['h01']
['h02', 'h03']
['h04', 'h05', 'h06']
serial: [2, 4]
['h01', 'h02']
['h03', 'h04', 'h05', 'h06']
serial: 100
['h01', 'h02', 'h03', 'h04', 'h05', 'h06']An integer larger than the host count produces a single batch. serial
never invents hosts and never splits a host.
The list keeps going after it runs out
A list shorter than the fleet needs does not stop at its last entry. The final value repeats until every host has been covered.
$ ansible-playbook -i inv6.ini batches.yml # serial: [1, 2]six hosts, serial: [1, 2]
['h01']
['h02', 'h03']
['h04', 'h05']
['h06']
twenty hosts, serial: [1, 2]
['h01']
['h02', 'h03']
['h04', 'h05']
['h06', 'h07']
... continues in twos ...
['h18', 'h19']
['h20']Upstream states this directly: with serial: [1, 5, 10], “the first
batch would contain a single host, the next would contain 5 hosts, and
(if there are any hosts left), every following batch would contain
either 10 hosts or all the remaining hosts.”
The practical consequence is that a list is a ramp, not a schedule.
[1, 5, 25%] does not mean “three batches”. It means “one, then five,
then a quarter at a time for as long as it takes”.
A percentage rounds down, and resolves against the whole play
This is the form that surprises people, and it is worth executing rather than assuming.
$ ansible-playbook -i inv6.ini batches.ymlsix hosts:
serial: "25%" -> 1, 1, 1, 1, 1, 1 (25% of 6 = 1.5)
serial: "30%" -> 1, 1, 1, 1, 1, 1 (30% of 6 = 1.8)
serial: "49%" -> 2, 2, 2 (49% of 6 = 2.94)
serial: "50%" -> 3, 3 (50% of 6 = 3.0)
serial: "51%" -> 3, 3 (51% of 6 = 3.06)
twenty hosts:
serial: "10%" -> 2 x 10 batches
serial: "25%" -> 5, 5, 5, 5
serial: "30%" -> 6, 6, 6, 2
serial: "33%" -> 6, 6, 6, 2 (33% of 20 = 6.6)Three things fall out of that table.
It rounds down, not to nearest. 25% of six hosts is 1.5, and the
batch is 1. 33% of twenty is 6.6, and the batch is 6. If you write
33% expecting “roughly a third, three batches”, you get four batches
with a ragged tail.
The 30%-of-twenty case matches upstream exactly — the
documentation uses this example and gives 6, 6, 6, 2. Executed here, it
produces 6, 6, 6, 2.
A percentage always resolves to at least one host. 1% of six hosts
is 0.06, and Ansible runs batches of one rather than batches of zero. A
play cannot make no progress because someone wrote a small percentage
against a small fleet.
The play restarts for every batch
This is the mechanism, and almost every surprising serial behaviour
follows from it.
A batched play is not one play that pauses. It is the same play
executed once per batch, against a different set of hosts each time.
The output makes this visible: the PLAY [...] banner repeats.
$ ansible-playbook -i inv6.ini deploy.ymlPLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h01]
changed: [h02]
RUNNING HANDLER [Restart service] **********************************************
ok: [h01]
ok: [h02]
PLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h03]
changed: [h04]
RUNNING HANDLER [Restart service] **********************************************
ok: [h03]
ok: [h04]
PLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h05]
changed: [h06]
RUNNING HANDLER [Restart service] **********************************************
ok: [h05]
ok: [h06]Read that as three executions, not as one execution with markers in it. Everything that happens “once per play” now happens once per batch:
| Construct | Under serial: 2 over six hosts |
|---|---|
pre_tasks | runs three times, once per batch |
post_tasks | runs three times |
| Handler flush at end of play | three flushes |
roles: | applied three times, to different hosts |
run_once: true | runs three times — once per batch |
gather_facts | gathers for the batch, not the fleet |
The run_once row is the one that causes production incidents, and it
gets its own treatment in the delegation part. The short version:
run_once means once per batch, so a “run once” database migration
under serial: 5 runs as many times as there are batches.
Knowing which hosts you are in
Three variables answer “who is in scope”, and under serial they stop
being the same thing.
| Variable | Meaning under serial |
|---|---|
ansible_play_batch | the hosts in this batch that are still active |
ansible_play_hosts | the same as ansible_play_batch in a batched play — active hosts of the current execution |
ansible_play_hosts_all | every host the play targets, unaffected by batching |
Verified on 2.21.3: across all three batches of a serial: 2 run over
six hosts, ansible_play_hosts_all | length reported 6 every time,
while ansible_play_batch reported the two hosts of that batch.
- name: Record which hosts are in this batch
ansible.builtin.debug:
msg: >-
batch {{ ansible_play_batch | length }} of
{{ ansible_play_hosts_all | length }} total:
{{ ansible_play_batch | join(', ') }}
run_once: truerun_once is exactly right here, because you want one line per batch,
and “once per batch” is what run_once actually does.
Choosing a batch shape
The shape encodes what you are afraid of.
serial: 1 — one host at a time. The slowest and the safest. For a
fleet of 300 with a two-minute play, that is ten hours, so it is a shape
for small critical groups, not for fleets.
serial: [1, 5, 25%] — the canary ramp, and the default answer for
most production changes. One host proves the change works at all. Five
prove it works on more than a lucky host. Then a quarter at a time,
because by that point the risk is different in kind: you are no longer
asking “does this work” but “does this scale”.
A percentage alone — serial: "10%" — keeps a fixed proportion of
the fleet in flight regardless of how the fleet grows. Reasonable for
convergence runs that do not restart anything. As a shape for a
service-impacting change it is worse than the ramp, because it puts 10%
of the fleet into the first batch, and the first batch is where an
untested change is most likely to be wrong.
Capacity is the constraint that overrides all of this. If the
service needs 80% of its instances to carry peak traffic, no batch may
exceed 20% of the fleet, and that arithmetic has nothing to do with
Ansible. Work out the largest batch the service can lose, then choose a
serial at or below it.
Knowledge check
Knowledge check · 4 questions
Q1. A play targets 6 hosts with serial: "25%". How many hosts are in each batch?
Q2. A play targets 20 hosts with serial: [1, 5, "25%"]. What is the size of the third batch?
Q3. Under serial: 2 over six hosts, which of these happen three times rather than once? Select all that apply.
Q4. With serial: [1, 5], a 40-host play runs one batch of a single host and then batches of five until the whole fleet has been covered.
Passing score: 75%. Answers are checked in this browser.