include_tasks vs import_tasks
include_tasks has already shown up twice in this course — fixing the nested-loop collision, and working around the fact that block: can’t be looped directly — both times without a full explanation of what it actually is or why it was the right tool. This chapter delivers that, alongside its counterpart, import_tasks, and the one fundamental difference between them that explains every practical consequence covered in this section.
Two Ways To Pull In Tasks From Another File
Both import_tasks and include_tasks let you split a play’s tasks into a separate file and pull them in. The difference isn’t in what they let you do — it’s entirely in when that pulling-in actually happens.
import_tasks: Resolved At Parse Time
Ansible reads and expands import_tasks before the play starts running at all — at parse time, the imported file’s tasks are spliced directly into the play’s task list, effectively as if you’d copy-pasted them there yourself. This happens before a single task has executed, before facts have been gathered, before anything set during a run exists yet.
include_tasks: Resolved At Runtime
include_tasks is resolved during execution, exactly when the play actually reaches that point in its task list. At that moment, Ansible goes and reads whatever file is specified — which, critically, can be determined by something only known by then, like a fact or a registered result.
Basic Syntax, Side By Side
# import (static)
- name: Import setup tasks
ansible.builtin.import_tasks: setup.yaml# include (dynamic)
- name: Include setup tasks
ansible.builtin.include_tasks: setup.yamlFor a fixed filename like this, they look identical and behave identically too. The difference only becomes visible — and important — the moment the filename itself depends on something.
The Practical Consequence: A Runtime-Only Path
Since import_tasks is resolved before the play runs at all, its filename cannot depend on anything only known at runtime — a fact, a registered variable, anything gathered during execution. At parse time, none of that exists yet. include_tasks, resolved later, has no such restriction.
Proving It Concretely
- name: Debian-specific task
ansible.builtin.debug:
msg: "Running Debian-specific setup"- name: RedHat-specific task
ansible.builtin.debug:
msg: "Running RedHat-specific setup"- name: Play
hosts: servers
tasks:
- name: Try import with a runtime-dependent path (BROKEN)
ansible.builtin.import_tasks: "tasks_{{ ansible_facts['os_family'] | lower }}.yaml"ansible-playbook broken_import.yaml[ERROR]: Error when evaluating variable in import path
"tasks_{{ ansible_facts['os_family'] | lower }}.yaml": 'ansible_facts' is undefinedThis fails — typically with an error about the referenced file not being found, since ansible_facts doesn’t exist yet at the point import_tasks tries to resolve its filename. Facts are gathered during the play; import_tasks is expanded before the play even starts.
- name: Include with the same runtime-dependent path (WORKS)
ansible.builtin.include_tasks: "tasks_{{ ansible_facts['os_family'] | lower }}.yaml"TASK [Include with the same runtime-dependent path (WORKS)] ************************
included: /home/alice/codes/ansible/include_import/tasks_debian.yaml for ubuntu
included: /home/alice/codes/ansible/include_import/tasks_redhat.yaml for fedora
TASK [Debian-specific task] ********************************************************
ok: [ubuntu] => {
"msg": "Running Debian-specific setup"
}
TASK [RedHat-specific task] ********************************************************
ok: [fedora] => {
"msg": "Running RedHat-specific setup"
}This works, because by the time execution actually reaches this line, facts have already been gathered — ansible_facts['os_family'] genuinely exists, and the correct file gets included based on it.
Best Practices
- Use
import_tasksfor content that’s always the same, known entirely in advance — no dependency on facts, registered results, or anything else only available once the play is actually running. - Use
include_tasksany time the included file’s identity depends on something only known at runtime — this is exactly why the nested-loop and block-looping workarounds earlier in this course both needed it. - Default to
include_taskswhen in doubt — it’s the more flexible of the two.import_tasks’s stricter, earlier resolution has real, specific benefits of its own, covered fully in this section’s next chapter.