Ansible offers two ways to break a playbook into reusable parts:
- import_* — static. The included file's contents are inlined at parse time, before any task runs.
- include_* — dynamic. The file is loaded and executed when control reaches the include task at runtime.
Both look similar but behave very differently when combined with tags, conditionals, or loops.
| Directive | What it pulls in | Static / Dynamic |
|---|---|---|
import_playbook | An entire playbook file (only at the top level) | Static |
import_tasks | A list of tasks from another file | Static |
import_role | A role's tasks/handlers/vars at this point in the play | Static |
include_tasks | A list of tasks from another file | Dynamic |
include_role | A role's tasks at runtime | Dynamic |
include_vars | Variables from a YAML/JSON file | Dynamic |
---
- hosts: all
tasks:
- name: OS hardening tasks
ansible.builtin.import_tasks: harden.yml
tags: [hardening]
At parse time Ansible reads harden.yml and inlines all of its tasks. The
tags: [hardening] attribute is applied to every imported task, so any of
them can be run individually with --tags hardening.
---
- hosts: all
tasks:
- name: OS-specific setup
ansible.builtin.include_tasks: "setup-{{{{ ansible_distribution }}}}.yml"
# this can't be import_tasks — the path uses a fact that doesn't exist yet at parse time
At runtime, Ansible evaluates the variable, finds setup-OracleLinux.yml, and runs
the tasks from it. Tags on this include_tasks apply only to the include itself —
not to inner tasks (because they don't exist at tag-parse time).
Only include_tasks can loop. import_tasks doesn't support loop::
- name: Configure each tenant
ansible.builtin.include_tasks: tenant-setup.yml
vars:
tenant: "{{{{ item }}}}"
loop:
- acme
- globex
- initech
# tenant-setup.yml is run THREE times, with tenant set to each value
| Behaviour | import_* | include_* |
|---|---|---|
| Resolved when? | Parse time | Runtime |
| Path can use variables? | No | Yes |
| Tags apply to inner tasks? | Yes | No (only to the include itself) |
| Can be looped? | No | Yes |
| Notify handlers in another file? | Yes | Yes (for include_tasks; trickier for include_role) |
| Faster? | Slightly — parsed once | Slightly slower (per-iteration parse) |
| Easier to debug? | Yes — flat task list | No — dynamic resolution hides tasks |
- Need to use a variable in the path or in a loop? →
include_* - Need
--tagsto filter inner tasks individually? →import_* - Just splitting one big file into smaller files? →
import_*(static is simpler) - Generating tasks based on facts gathered at runtime? →
include_*
import_playbook is special — it can only appear at the top level of a playbook
file, not inside tasks:. It chains multiple playbooks together:
---
# site.yml — top-level orchestrator
- import_playbook: 01-os-prep.yml
- import_playbook: 02-database-tier.yml
- import_playbook: 03-web-tier.yml
- import_playbook: 04-smoke-tests.yml
# Run the whole thing:
# ansible-playbook site.yml
# Or just one:
# ansible-playbook 02-database-tier.yml
import_playbook for top-level orchestration, import_tasks for splitting big task files, and include_tasks only when you genuinely need runtime variability. That covers 95% of real-world reuse.You've now covered the full playbook surface area: structure, variables and precedence, group_vars/host_vars layout, Jinja2 templating, conditionals, loops, handlers, blocks, error handling, and the import vs include distinction. With these in your toolkit you can read and write any production playbook.
Next up — Section 3: Roles and Reuse (6 pages, 20–25). You'll learn how to package those scattered tasks into reusable roles, manage role dependencies, distribute them through Ansible Galaxy, and structure a library of roles that scales across teams.