Skip to content

Ansible: playbook vs play vs task vs role vs handler

Mental model

Ansible's execution hierarchy is: playbook > play > task. Roles are reusable bundles of tasks. Handlers are tasks that fire only when notified. Think of it as: the playbook is the script, plays are scenes, tasks are individual stage directions.

What it looks like

"I write a playbook to configure my servers." People see one YAML file and treat everything in it as "the playbook" without distinguishing the nested layers.

What it really is

  • Playbook: a YAML file containing an ordered list of plays. One file, potentially many plays.
  • Play: maps a group of hosts to a list of tasks (and roles). Each play has its own hosts:, vars:, become:, etc.
  • Task: one action — calls a single module with parameters. The atomic unit of work. Runs on each targeted host.
  • Role: a reusable directory structure bundling tasks, handlers, vars, defaults, templates, and files under a standard layout.
  • Handler: a task that runs only when notified by name, and only once at the end of the play (or when explicitly flushed).

Why it seems confusing

YAML structure makes everything look alike — plays, tasks, and role includes are all list items at varying indentation levels. The boundaries between "play-level keywords" and "task-level keywords" are not visually obvious. Roles add another layer of indirection that hides where tasks actually live.

What actually matters

  • Nesting order: playbook contains plays, plays contain tasks (and/or roles), roles contain tasks + handlers + vars.
  • Each play targets hosts independently. Different plays in one playbook can target different host groups.
  • Handlers exist because you often need "restart nginx only if config changed." The notify mechanism deduplicates: even if three tasks notify the same handler, it runs once.
  • Roles enforce structure. A role named nginx lives at roles/nginx/{tasks,handlers,defaults,vars,templates,files}/.

Common mistakes

  • Confusing play-level keys (hosts, become, roles) with task-level keys (name, module, register, when).
  • Putting everything in one giant play instead of splitting by host group or logical phase.
  • Expecting handlers to run immediately after the notifying task (they run at end of play; use meta: flush_handlers if needed).
  • Writing roles without defaults/main.yml (makes overriding variables harder than necessary).

Small examples

# playbook.yml — two plays, one playbook
- name: Configure web servers          # PLAY 1
  hosts: webservers
  become: true
  roles:
    - nginx
  tasks:
    - name: Copy site config            # TASK
      ansible.builtin.template:
        src: site.conf.j2
        dest: /etc/nginx/conf.d/site.conf
      notify: Restart nginx             # triggers handler

  handlers:
    - name: Restart nginx               # HANDLER
      ansible.builtin.service:
        name: nginx
        state: restarted

- name: Configure database servers      # PLAY 2
  hosts: dbservers
  become: true
  tasks:
    - name: Install PostgreSQL
      ansible.builtin.package:
        name: postgresql
        state: present
# Role directory layout
roles/nginx/
  tasks/main.yml
  handlers/main.yml
  defaults/main.yml      # lowest-priority variables
  vars/main.yml          # higher-priority variables
  templates/
  files/

One-line summary

A playbook holds plays, plays target hosts and hold tasks, tasks call modules, roles bundle tasks into reusable units, and handlers are tasks that only fire when notified.