AnsibleXXII · Roles and ReuseRoles and reuse
Static and dynamic role reuse
What you'll learn
- State which forms are resolved at parse time and which at run time
- Predict what --list-tasks shows for each form, and why the difference matters
- Choose between import and include from the operational consequence rather than habit
- Recognise a dry run that under-reports what a play will do
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
There are four ways to pull other content into a play, and they divide into two pairs:
| Static — parse time | Dynamic — run time |
|---|---|
roles: keyword | — |
ansible.builtin.import_role | ansible.builtin.include_role |
ansible.builtin.import_tasks | ansible.builtin.include_tasks |
Everything else follows from that one distinction. Static forms are expanded when the playbook is parsed, before a host is contacted. Dynamic forms are a task that runs, and what it pulls in does not exist until it does.
The consequences are operational rather than stylistic, and they are the sort
of thing people repeat from memory incorrectly. Every claim below was
executed against ansible-core 2.21.3.
What the parser knows
Two roles, alpha and beta. alpha has two tasks, one of them tagged
alpha_inner. beta has one. A play imports the first and includes the
second, tagging both statements patch:
- name: static versus dynamic
hosts: web
gather_facts: false
tasks:
- name: static import_role
ansible.builtin.import_role:
name: alpha
tags: [patch]
- name: dynamic include_role
ansible.builtin.include_role:
name: beta
tags: [patch]
ansible-playbook -i inventory.ini listtasks.yml --list-tasks$ ansible-playbook -i inventory.ini listtasks.yml --list-tasksplaybook: listtasks.yml
play #1 (web): static versus dynamic TAGS: []
tasks:
alpha : alpha task one TAGS: [alpha_inner, patch]
alpha : alpha task two TAGS: [patch]
dynamic include_role TAGS: [patch]Read that carefully, because four separate facts are visible in five lines:
- The imported role is expanded. Both of
alpha’s tasks appear, with the role-name prefix. - Tags on the import statement propagate to every task inside it. Both
alphatasks carrypatch, although neither declares it. - A task’s own tags are additive.
alpha task onehas bothalpha_innerandpatch. - The included role is one line.
beta’s task does not appear at all. The parser does not know what is insidebeta, because it has not looked.
The same split applies at task-file level. import_tasks expands; include_tasks
does not:
$ ansible-playbook -i inventory.ini incimp.yml --list-tasksplaybook: incimp.yml
play #1 (local): local TAGS: []
tasks:
shared task one TAGS: [inner, staticpart]
shared task two TAGS: [staticpart]
dynamic include_tasks TAGS: [dynpart]
dynamic include_tasks in a loop TAGS: []Why that costs you something
--list-tasks is the cheapest pre-flight check there is. It costs no
connection, no time and no risk, and it answers what is this run going to
do. A play built from dynamic includes answers that question with a list of
include statements.
The same blindness shows up in --list-tags, and there it is worse, because
a tag you rely on can be missing from the list entirely:
$ ansible-playbook -i inventory.ini dynonly.yml --list-tagsplaybook: dynonly.yml
play #1 (local): local TAGS: []
TASK TAGS: []An operator who runs --list-tags to find out what selections exist, sees
nothing, and concludes the play has no tag structure, is reading an accurate
report of what the parser knows and a false report of what the play contains.
What each form can and cannot do
The static forms trade flexibility for visibility, and the trade is enforced rather than advisory. Attempting to loop a static form is a hard parse error with a message that names the fix:
$ ansible-playbook -i inventory.ini importloop.yml --syntax-check[ERROR]: You cannot use loops on 'import_tasks' statements. You should use 'include_tasks' instead.
Origin: /srv/automation/importloop.yml:4:7
2 gather_facts: false
3 tasks:
4 - name: import_tasks with a loop
^ column 7$ ansible-playbook -i inventory.ini importroleloop.yml[ERROR]: You cannot use loops on 'import_role' statements. You should use 'include_role' instead.
Origin: /srv/automation/importroleloop.yml:4:7
2 gather_facts: false
3 tasks:
4 - name: import_role with a loop
^ column 7Summarised:
import_role / import_tasks | include_role / include_tasks | |
|---|---|---|
| Resolved | Parse time | Run time |
Appears in --list-tasks | Expanded, task by task | One line for the statement |
| Tags on the statement | Applied to every task inside | Applied to the statement only |
Inner tags visible to --list-tags | Yes | No |
loop on the statement | Parse error | Supported |
when on the statement | Copied onto every inner task, evaluated per task | Evaluated once, for the include |
| Filename can be templated from a run-time fact | No | Yes |
The when row deserves a sentence. On a static import, when is not
evaluated once for the block — it is copied onto each imported task and
evaluated individually. Usually indistinguishable, but not always: if an
early task in the imported file changes the thing the condition tests, later
tasks in the same import can evaluate it differently.
Choosing
The decision is nearly always the same one:
Default to static. roles:, import_role, import_tasks. You get a
complete dry run, working --tags, and a --list-tasks you can diff between
branches to prove a refactor changed nothing.
Use dynamic when you need what only it does. Three genuine cases:
- A loop. Applying a role once per virtual host, per database, per environment.
- A path built from run-time information. The per-platform task file
selected by
ansible_facts['os_family']cannot be resolved before facts are gathered. - A conditional whose answer is expensive or unknowable at parse time, where you want to skip loading the content at all rather than skip its tasks individually.
Everything else — “it feels cleaner”, “the file is big” — is not a reason, and it costs you the dry run.
The dynamic form’s own conveniences
When you do use include_role, two options are worth knowing.
tasks_from: selects an entry point other than main.yml, which is how a
role exposes more than one operation:
$ ansible-playbook -i inventory.ini tasksfrom.ymlTASK [include only the verify entry point] *************************************
TASK [multi : multi verify] ****************************************************
ok: [localhost] => {
"msg": "verify entry"
}
TASK [include with role vars] **************************************************
TASK [multi : multi main] ******************************************************
ok: [localhost] => {
"msg": "main entry, greeting=passed-in"
}- name: include only the verify entry point
ansible.builtin.include_role:
name: multi
tasks_from: verify
- name: include with role vars
ansible.builtin.include_role:
name: multi
vars:
multi_greeting: passed-in
tasks_from pairs with the entry points in meta/argument_specs.yml from
lesson 4: a role with tasks/verify.yml should declare a verify: entry
point so its inputs are validated too.
Knowledge check
Knowledge check · 4 questions
Q1. A play contains only ansible.builtin.include_tasks pointing at a file whose tasks carry a tag. What does --list-tags report for that play?
Q2. Which are true of tags placed on an import_role statement? Select all that apply.
Q3. Putting a loop on an import_role statement is a parse error, and the error message names include_role as the alternative.
Q4. Which of these is a legitimate reason to choose include_role over import_role for production work?
Passing score: 75%. Answers are checked in this browser.