import versus include

Scenario: A playbook uses include_tasks inside a loop. Running --tags database skips it entirely, and --list-tasks shows nothing from that file.

New words, in plain English

Ansible has two ways to pull in another task file, and they differ in when the file is read. That timing difference produces every practical consequence.

*import_ is static. The file is expanded when the playbook is parsed, so its tasks become ordinary tasks in the play. They appear in --list-tasks, tags on the import are applied to each task individually, and each task can be targeted by --start-at-task. Because expansion happens before any host is touched, an import cannot** be looped and cannot depend on a run-time value.

*include_ is dynamic.** The file is read when execution reaches it. It can be looped, and its path can be built from a variable or a fact. The cost is invisibility: --list-tasks cannot see inside it, and a tag on the include controls only whether the include happens - it does not propagate to the tasks within.

That last point is the scenario. Tagging an include_tasks with database and then running --tags database runs the include - but the tasks inside carry no tags, so under a tag filter they are all skipped. It looks like the include did nothing.

The rule: *prefer import_. Use include_ only when you genuinely need a loop or a run-time-determined path.*

Analogy: import is a photocopy stapled into the document before printing - everything is visible in the table of contents. include is a footnote saying "see the other file when you get here" - fine, but the index cannot list what is in it.

A worked example

# STATIC - visible to --list-tasks, tags apply to each task inside
- name: Database tier configuration
  ansible.builtin.import_tasks: tasks/database.yml
  tags: [database]

# DYNAMIC - required here, because it loops
- name: Configure each application instance
  ansible.builtin.include_tasks: tasks/instance.yml
  loop: "{{ app_instances }}"
  loop_control:
    loop_var: instance

# DYNAMIC - required here, because the path depends on a fact
- name: Load OS-specific tasks
  ansible.builtin.include_tasks: "tasks/{{ ansible_facts['os_family'] }}.yml"

# Making tags work with a dynamic include:
- ansible.builtin.include_tasks: tasks/database.yml
  tags: [database, always]        # 'always' ensures the include is evaluated
# ...and tag the tasks INSIDE database.yml as well.

There is a matching subtlety with conditionals. A when on an import_tasks is copied onto every imported task and evaluated individually. A when on an include_tasks is evaluated once, for the include itself. Usually the outcome is the same; when the condition depends on something that changes mid-file, it is not.

The same static/dynamic split applies to import_playbook (static, top level only) and to import_role/include_role. The reasoning transfers directly: static gives visibility and tag propagation, dynamic gives run-time flexibility.

Warning: A tag on include_tasks does not reach the tasks inside it. Under --tags, the include runs and everything within it is skipped - which looks exactly like a broken include.
Goal: Put this to work in the ansible-tags-partial-run lab. Open /labs/ansible, pick ansible-tags-partial-run, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.