Chapter 4: Playbooks — The Core
Topic 25

Play Execution Lifecycle

ConceptWorkflow

A play runs in a fixed order: gather facts, then run tasks top to bottom, then run any notified handlers at the end — repeated across every host in the play. Understanding this lifecycle is what lets you predict exactly when each thing happens, instead of being surprised that a reload landed later than you expected or a fact was empty.

The fixed order of a play
gather facts
run tasks top to bottom
flush handlers
next play

The lifecycle rests on Ansible's defining model: there is no state file. Every run gathers the live machine's facts and reconciles against them, the server itself being the source of truth. This topic walks the three phases in order, explains how the default strategy sequences work across hosts, and shows why a second run reconverges to near-zero change without any stored memory of the first.

Fact Gathering First

Unless you disable it, a play begins by running the setup module on each host to collect facts — the OS, the IP addresses, memory, mounted filesystems, and much more. This live snapshot is the closest thing Ansible has to "reading state," and it exists precisely because there is no state file. The machine is inspected fresh at the start of every run rather than recalled from a stored record.

Those facts are then available to every task and template in the play as variables like ansible_default_ipv4.address. Because they are gathered live, they always describe the machine as it is right now, not as it was the last time something ran. That freshness is the upside of having no state file to drift out of date.

Tasks Top to Bottom

After facts, tasks run in the order you wrote them. Each task connects, checks current state, acts only if needed, and reports ok, changed, or failed. The ordered list is deliberate, because configuration often depends on sequence — create the user, then its home directory, then the files it owns — and Ansible runs that sequence exactly as written.

Ansible builds no dependency graph and infers no order. The list is the contract: if task five uses a file that task seven creates, the play fails, because Ansible will not reorder them to satisfy the dependency. Writing tasks in true dependency order is your job, and it is the most direct lever you have over what happens when.

Handlers at the End

Notified handlers run after the last task of the play, once each, in the order they were defined — not the order they were notified. This is why a reload happens after all the config edits that triggered it, rather than in the middle of them. More precisely, Ansible flushes notified handlers automatically at the end of each section — after pre_tasks, after roles and tasks, and after post_tasks; in a plain tasks: play that is simply the end. Its timing is the source of the most common handler surprise.

If a later task needs a handler to have already run, the lifecycle will not do it for you — that is exactly what meta: flush_handlers from the previous topic forces. Absent a flush, every notified handler waits for the end. Knowing the handler phase sits after all tasks is what makes that behavior predictable rather than mysterious.

Per-Host vs Per-Task Ordering

By default Ansible uses the linear strategy: it runs each task across all hosts before moving to the next task. The play advances task-by-task in lockstep across the fleet, so task one finishes on web1 and web2 before task two starts on either. Within a single host, tasks are still strictly sequential — the lockstep is across hosts, per task.

This is the opposite of "finish one host completely, then start the next," which trips people up when they expect host-at-a-time progress. The strategies that change this, and serial for rolling batches, get their own topic later. For now the default to remember is task-by-task across the fleet, with each host's tasks in order.

Re-Convergence, Every Run

Because there is no stored state, running the same playbook again simply repeats the whole lifecycle — gather facts, run tasks, run handlers — and settles to the same end state, reporting near-zero changed. The run is idempotent not because Ansible remembers what it did, but because each module re-checks the live machine and acts only on a real difference it finds there.

That distinction is the heart of the no-state model. A second run that reports a high changed count usually means a non-idempotent command or shell task that re-checks nothing and reports changed every time, not a real difference on the host. Prefer real modules and the count drops to near-zero, which is the proof that the live machine matches the declared state.

Ansible's run lifecycle vs Terraform's apply

Terraform — reads its state file, diffs it against the config and the real cloud, builds a dependency graph, and applies changes in graph order. The engine computes the sequence, so ordering is Terraform's responsibility, derived from the resources' dependencies.

Ansible — gathers live facts with no state file, runs an ordered task list per host, then handlers. The order comes from the sequence you wrote, not a computed graph, which is why sequencing is your responsibility in a playbook and the engine's in Terraform.

Common Mistakes
  • Assuming handlers run at the point you notify them — they run at the end of the play, so a task that depends on the reload having happened must use meta: flush_handlers, not just sit after the notify.
  • Referencing a fact like ansible_default_ipv4.address in a play that ran with gather_facts: false, so the variable is undefined and the template renders empty or errors.
  • Expecting tasks to complete on one host before the next host starts — the default linear strategy advances task-by-task across all hosts, so progress is per-task, not per-host.
  • Treating a second run that reports changed on a task as a bug, when it usually means a non-idempotent command or shell task that re-checks nothing and reports changed every time.
  • Writing tasks in an order that assumes a dependency the list does not enforce — using a file before the task that creates it — since Ansible runs your order literally and builds no graph.
Best Practices
  • Rely on the lifecycle — facts, then ordered tasks, then handlers — and place a meta: flush_handlers exactly where a later task needs an earlier reload to have already run.
  • Keep gather_facts on, the default, when tasks or templates reference facts, and disable it only for plays that touch no facts to save the setup round-trip.
  • Write tasks in true dependency order, since Ansible executes the list as written and infers no graph — the sequence is your contract with the engine.
  • Make every task idempotent by preferring real modules over command and shell, so a repeat run reconverges to zero changed and proves the live machine matches the declared state.
  • Expect task-by-task progress across the fleet under the default strategy, and reach for serial and the other strategies only when you need rolling, host-batched rollout.
Comparable tools Terraform reads a state file and applies in graph order — the opposite model Puppet gathers facts via Facter and applies a compiled catalog Shell script runs top to bottom with no facts, idempotency, or handler phase setup module the fact-gathering step that opens every play

Knowledge Check

What are the three phases of a play's lifecycle, in order?

  • Gather facts, run tasks top to bottom, then run notified handlers at the end
  • Run notified handlers first, gather facts next, then run the tasks top to bottom
  • Read the stored state file, diff it against the hosts, then apply changes in dependency-graph order
  • Run the tasks first, gather facts afterward, then run handlers as a final cleanup pass

Why do notified handlers run at the end of the play rather than at the moment of the notify?

  • The handler phase is a distinct stage after the task list, so a reload happens once after all the config edits that triggered it
  • Handlers are inherently slower than ordinary tasks because each one restarts a service, so Ansible defers them all to the end to avoid blocking the faster task list mid-run
  • A notified handler must wait for the next playbook run before it is allowed to execute
  • Handlers actually run at notify time, and the supposed end-of-play timing is a common myth

Given that Ansible has no state file, what does fact gathering read?

  • The live machine — the setup module inspects each host fresh every run, since there is no stored record to recall
  • A cached snapshot taken on the previous run and stored on the control node between runs, which the setup module reads back by default instead of re-querying the host
  • The inventory file, which is where each host's last-known facts are written and read back
  • A hidden Terraform-style state file that Ansible quietly keeps per host on disk

Under the default linear strategy, how is work ordered across a fleet of hosts?

  • Task-by-task across all hosts — each task finishes on every host before the next begins, while staying sequential within a host
  • Host-by-host — one host runs its entire task list to completion before the next host in the inventory is allowed to start its own first task
  • All tasks on all hosts fire fully in parallel at once, with no ordering or synchronization between them
  • In whatever order the individual hosts happen to respond to the SSH connection fastest

You got correct