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
nginxlives atroles/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_handlersif 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.