Skip to main content
RunBook Academy

AnsibleXXXI · Serial Execution and Failure ToleranceSerial Execution

serial: batching a play

Advanced⏱ ~24 minansible-playbook

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

Not yet marked complete on this device.

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

Service impact possiblea fleet-wide simultaneous restart
- 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: restarted

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

Service impact possiblethe same change, staged
- 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: restarted

The three forms

serial accepts an integer, a percentage string, or a list of either.

Read-only / Safebatch composition, executed on ansible-core 2.21.3
$ ansible-playbook -i inv6.ini batches.yml
serial: 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.

Read-only / Safea two-entry list over six and over twenty hosts
$ 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.

Read-only / Safepercentage arithmetic, executed on 2.21.3
$ ansible-playbook -i inv6.ini batches.yml
six 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.

Read-only / Safesix hosts, serial: 2, executed on 2.21.3
$ ansible-playbook -i inv6.ini deploy.yml
PLAY [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:

ConstructUnder serial: 2 over six hosts
pre_tasksruns three times, once per batch
post_tasksruns three times
Handler flush at end of playthree flushes
roles:applied three times, to different hosts
run_once: trueruns three times — once per batch
gather_factsgathers 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.

VariableMeaning under serial
ansible_play_batchthe hosts in this batch that are still active
ansible_play_hoststhe same as ansible_play_batch in a batched play — active hosts of the current execution
ansible_play_hosts_allevery 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.

Read-only / Safeprinting the batch you are actually in
- 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: true

run_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 aloneserial: "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

  1. Q1. A play targets 6 hosts with serial: "25%". How many hosts are in each batch?

  2. Q2. A play targets 20 hosts with serial: [1, 5, "25%"]. What is the size of the third batch?

  3. Q3. Under serial: 2 over six hosts, which of these happen three times rather than once? Select all that apply.

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