unit 6 · Making it real

import vs include

Parse time or runtime — pick on purpose

essential · 6 min · linux + windows

you may want to read 24 · Roles: structure, defaults vs vars first — but nothing is locked

tl;dr

  • import_tasks/import_role are static: resolved at parse time. include_tasks/include_role: at runtime.
  • Default to import for parse-time checking; use include for variable filenames, loops, per-host paths.
  • Bare include: was removed in ansible-core 2.16 — playbooks still using it fail to parse.

why it matters Every role and split-out task file enters a play through one of these keywords; the wrong pick breaks variable filenames and loops, or throws away the parse-time checks that catch typos early.

Lesson 24 left your work split across files — role task files, plus any long play you break into pieces. Two keyword families pull those files back in, and they differ on one axis: when Ansible resolves them. Everything in this lesson falls out of that fact.

import_tasks and import_role are static. Ansible resolves them at parse time — while reading your playbook, before contacting a single host. The imported tasks are inlined into the play, as if you’d pasted them in by hand.

include_tasks and include_role are dynamic. Ansible resolves them at runtime, when execution reaches that task. By then facts and per-host variables exist and can decide what gets pulled in.

- name: Baseline packages # static — inlined at parse time
  ansible.builtin.import_tasks: baseline.yml

- name: OS-specific setup # dynamic — file name resolved per host, at runtime
  ansible.builtin.include_tasks: '{{ ansible_os_family }}.yml'
one axis: resolved while parsing vs. resolved mid-run

What static buys you

Because imports are inlined while parsing, the entire play is known before the run starts. A typo’d module name or broken YAML inside an imported file fails immediately — not twenty minutes into a change window. --list-tasks shows the imported tasks by name, and --start-at-task can jump straight to one (both return in lesson 29). Boring, checkable, predictable — exactly what you want by default.

What dynamic buys you

An include is just a task until it runs. That’s the point: at runtime Ansible knows things the parser can’t — which host it’s on, what facts came back, what a loop variable currently holds. Only includes can do these:

  • Variable file names. '{{ ansible_os_family }}.yml' picks a different file per host. Parse time has no per-host anything, so import can’t.
  • Loops. loop: on an import is a hard error; on include_tasks or include_role it processes the file once per item.
  • Skip the file entirely. A false condition on an include means the file is never even opened.
- name: Apply one hardening role per item
  ansible.builtin.include_role:
    name: '{{ item }}' # runtime value — import_role could never resolve this
  loop:
    - firewall_baseline
    - audit_policy
a loop forces include — and so does the variable role name

The bill for that flexibility: the parser can’t see inside. --list-tasks shows the include line and nothing beneath it. A typo inside the included file only surfaces when execution arrives.

The decision rule is short. Import by default; include when you need runtime behavior. A literal file name, no loop, no per-host branching — import it and take the free checking. The moment a variable, loop, or per-host decision enters the picture, switch to include.

The ancestor: bare include:

Before these two families existed, one keyword tried to be both at once:

old — don't write this · removed in core 2.16
# old repos and pre-2.16 tutorials are full of this
- include: legacy-tasks.yml

Its half-static, half-dynamic behavior was ambiguous, so it was deprecated in ansible-core 2.12 and removed in 2.16 . On a modern control node, a playbook containing it fails to parse. Like bare module names in lesson 4, it dates a repo at a glance. Replace it on sight: import_tasks by default, include_tasks if the call site needs runtime behavior.

One habit ties this lesson together: on an unfamiliar play, run --list-tasks first. What it shows is imported; what it hides is included — that gap isn’t a bug, it’s the design.

self-check — recall, not a test

  1. 1 A play has include_tasks: "{{ ansible_os_family }}.yml". Could you swap in import_tasks? Why or why not?

  2. 2 --list-tasks shows the tasks from one of your task files but not the other. What does that tell you?

  3. 3 You find a bare include: in an old repo. What happens on a modern control node, and what do you replace it with?

next up 26 · Galaxy, requirements.yml & pinning 6 min · unit 6