Why Ansible include_tasks Variables Are Not Defined in the Parent Play

You define a variable inside an include_tasks file. The task runs successfully. Then the very next task in your parent play fails with variable is undefined. What just happened?

This is one of the most confusing Ansible behaviors, and it bites people repeatedly because it looks like a bug. It’s not — it’s a scoping decision — but understanding why it works this way helps you fix it fast.

The Problem in Code

1
2
3
4
5
6
7
8
9
# main.yml
- hosts: localhost
  tasks:
    - name: Include helper tasks
      ansible.builtin.include_tasks: helper.yml

    - name: Use variable from helper
      ansible.builtin.debug:
        msg: "Result: {{ my_result }}"   # FAILS: my_result is undefined
1
2
3
4
# helper.yml
- name: Set a fact
  ansible.builtin.set_fact:
    my_result: "hello from helper"

You’d expect my_result to be available after the include. Sometimes it is. Sometimes it isn’t. The inconsistency is maddening.

Why This Happens

include_tasks is dynamic — Ansible resolves it at runtime, and the included tasks share the play’s variable space in most cases. However, set_fact inside an included task file can behave differently depending on Ansible version, whether the include is inside a loop, and how deeply nested it is.

The reliable pattern for returning a value from an included task file is set_fact — but you need to be deliberate about it.

Case 1: include_tasks Inside a Loop

This is the most common failure scenario:

1
2
3
4
5
- name: Process each item
  ansible.builtin.include_tasks: process_item.yml
  loop: "{{ items }}"
  loop_control:
    loop_var: current_item
1
2
3
4
# process_item.yml
- name: Set result
  ansible.builtin.set_fact:
    last_processed: "{{ current_item }}"

The variable last_processed gets set on each iteration, but the final value after the loop is unpredictable. Each loop iteration runs in its own include scope. The last iteration’s value may or may not propagate correctly to the parent.

Fix: Accumulate results explicitly:

1
2
3
4
# process_item.yml
- name: Append to results list
  ansible.builtin.set_fact:
    processed_results: "{{ processed_results | default([]) + [current_item] }}"

Case 2: Nested Includes

1
2
3
4
5
# main.yml
- include_tasks: level1.yml

- debug:
    var: deep_result   # May be undefined
1
2
# level1.yml
- include_tasks: level2.yml
1
2
3
# level2.yml
- set_fact:
    deep_result: "set at level 2"

Variables set two levels deep sometimes fail to propagate to the top level, especially in older Ansible versions (pre-2.8).

Fix: Explicitly re-set the variable at each level, or restructure to avoid deep nesting.

The Reliable Fix: import_tasks

If you don’t need dynamic includes (conditionals on the include itself, loops over the include), switch to import_tasks:

1
2
3
4
5
6
- name: Import helper tasks
  ansible.builtin.import_tasks: helper.yml

- name: Use variable from helper
  ansible.builtin.debug:
    msg: "Result: {{ my_result }}"   # Works reliably

import_tasks is static — Ansible processes it at parse time, not runtime. All tasks from the imported file are treated as if they’re inline in the parent play. Variables set via set_fact in imported tasks are always visible to subsequent tasks.

When you can’t use import_tasks (you need when: on the include, or you’re looping over includes), keep reading.

Fix for Dynamic Includes: Initialize First

If you must use include_tasks, initialize the variable before the include:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
- name: Initialize result
  ansible.builtin.set_fact:
    my_result: ""

- name: Include helper
  ansible.builtin.include_tasks: helper.yml

- name: Use result
  ansible.builtin.debug:
    msg: "{{ my_result }}"

This guarantees my_result exists in the play scope. When helper.yml sets it via set_fact, it overwrites the initialized value, and the parent play sees the update.

Fix for Loop Includes: Use a Results Accumulator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# main.yml
- name: Initialize results
  ansible.builtin.set_fact:
    sync_results: []

- name: Sync each environment
  ansible.builtin.include_tasks: sync_env.yml
  loop: "{{ environments }}"
  loop_control:
    loop_var: _env

- name: Show all results
  ansible.builtin.debug:
    var: sync_results
1
2
3
4
5
6
7
8
# sync_env.yml
- name: Do the work
  ansible.builtin.command: ./sync.sh {{ _env }}
  register: _sync_output

- name: Append result
  ansible.builtin.set_fact:
    sync_results: "{{ sync_results + [{'env': _env, 'rc': _sync_output.rc}] }}"

This pattern works reliably across all Ansible versions. Each iteration appends to a list defined in the parent scope.

Quick Diagnostic

When you hit an undefined variable after an include:

  1. Is the include inside a loop? → Use an accumulator pattern
  2. Is it nested more than one level? → Flatten or use import_tasks
  3. Is it conditional (when: on the include)? → Initialize the variable before the include
  4. None of the above? → Switch to import_tasks if possible; it’s always more predictable
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Safe pattern when you're not sure
- name: Set safe default
  ansible.builtin.set_fact:
    my_var: ""

- name: Conditionally include
  ansible.builtin.include_tasks: maybe_sets_var.yml
  when: some_condition

- name: Use result safely
  ansible.builtin.debug:
    msg: "{{ my_var | default('not set') }}"
  when: my_var | length > 0

The core rule: import_tasks shares scope unconditionally. include_tasks shares scope usually, but not reliably inside loops or deep nesting. When in doubt, initialize first and accumulate explicitly.