You set a variable with set_fact. You call include_role. Inside the role, the variable is undefined. You check the task order — it’s definitely set first. You add debug statements. The variable exists in the play but not inside the role.

This is one of the most confusing variable scoping issues in Ansible. Here’s what’s happening and how to fix it.

The Problem

1
2
3
4
5
6
7
8
9
- name: Set environment
  ansible.builtin.set_fact:
    MY_REGION: "us-east"

- name: Run the role
  ansible.builtin.include_role:
    name: my_role
  vars:
    some_param: "value"

Inside my_role/tasks/main.yml:

1
2
3
4
5
6
7
- name: Use the region
  ansible.builtin.debug:
    msg: "Region is {{ MY_REGION }}"  # Works fine

- name: Use some_param
  ansible.builtin.debug:
    msg: "Param is {{ some_param }}"  # Works fine

This looks like it should work, and usually it does. The problem appears in a specific scenario: when you pass a variable via vars: that shadows a play-level fact of the same name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Parent play
- name: Set region
  ansible.builtin.set_fact:
    REGION: "us-east"

- name: Run role with vars
  ansible.builtin.include_role:
    name: my_role
  vars:
    REGION: "{{ some_other_var }}"  # This can cause issues
    OTHER_VAR: "value"

When vars: contains a key that also exists as a play-level fact, Ansible’s variable precedence can behave unexpectedly — especially if some_other_var is undefined or resolves to an empty string.

The Real Scenario: Role Calling Another Role

The most common place this breaks is when one role calls another role internally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# roles/outer_role/tasks/main.yml

- name: Set environment key
  ansible.builtin.set_fact:
    REGION: "{{ env_string.split('_')[0] }}"
    ENV: "{{ env_string.split('_')[1] }}"

- name: Call inner role
  ansible.builtin.include_role:
    name: inner_role
  vars:
    some_profile: "readonly"

Inside inner_role, you expect REGION and ENV to be available because they were set as facts in the outer role. But inner_role has its own variable scope for the vars: block, and if inner_role also references REGION internally to build a lookup key, it may not find it.

The Fix: Pass Variables Explicitly in vars

The reliable fix is to pass the variables you need explicitly in the vars: block, even if they’re already set as facts:

1
2
3
4
5
6
7
- name: Call inner role
  ansible.builtin.include_role:
    name: inner_role
  vars:
    some_profile: "readonly"
    REGION: "{{ REGION }}"   # Explicitly pass the fact
    ENV: "{{ ENV }}"         # Explicitly pass the fact

This looks redundant but it guarantees the values are in scope for the vars: resolution context inside the role.

The Other Fix: Set Facts Before Passing Them

If you need to use Jinja2 expressions in your vars: values, resolve them to facts first:

1
2
3
4
5
6
# WRONG - can cause template parse errors
- name: Call role
  ansible.builtin.include_role:
    name: my_role
  vars:
    profile: "{{ credential_profile | default('readonly') }}"  # Single quotes inside double = parse error
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# RIGHT - resolve to a fact first
- name: Resolve profile
  ansible.builtin.set_fact:
    _resolved_profile: "{{ credential_profile | default('readonly') }}"

- name: Call role
  ansible.builtin.include_role:
    name: my_role
  vars:
    profile: "{{ _resolved_profile }}"  # Clean reference

This also avoids YAML quoting conflicts where single quotes inside a double-quoted Jinja2 expression cause template parse errors.

Variable Precedence Quick Reference

In Ansible, variables are resolved in this order (higher = wins):

  1. Extra vars (-e on command line) — always wins
  2. vars: on include_role — role-level scope
  3. set_fact — play-level scope
  4. Role defaults (defaults/main.yml) — lowest priority

The tricky part: when a vars: value references another variable that’s only in play-level scope, the resolution context isn’t always what you expect.

Debugging Variable Scope

When you’re not sure why a variable is undefined inside a role, add this at the top of the role:

1
2
3
4
- name: Debug all variables in scope
  ansible.builtin.debug:
    var: hostvars[inventory_hostname]
  tags: [never, debug_vars]

Run with:

1
ansible-playbook site.yml --tags debug_vars

This dumps everything the role can see. Compare it to what the parent play has to spot the missing variables.

Summary

  • set_fact variables are play-scoped and generally visible inside include_role
  • vars: on include_role creates a role-scoped overlay — if you need play-level facts available in this context, pass them explicitly
  • Avoid Jinja2 default() with quoted strings directly in vars: — resolve to a fact first
  • When debugging, dump hostvars[inventory_hostname] from inside the role to see exactly what’s in scope

The rule: if a variable needs to exist inside a role, either set it in the role’s defaults, or pass it explicitly via vars:. Don’t rely on play-level facts being automatically visible when vars: is involved.