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
| |
Inside my_role/tasks/main.yml:
| |
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.
| |
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:
| |
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:
| |
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:
| |
| |
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):
- Extra vars (
-eon command line) — always wins vars:oninclude_role— role-level scopeset_fact— play-level scope- 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:
| |
Run with:
| |
This dumps everything the role can see. Compare it to what the parent play has to spot the missing variables.
Summary
set_factvariables are play-scoped and generally visible insideinclude_rolevars:oninclude_rolecreates a role-scoped overlay — if you need play-level facts available in this context, pass them explicitly- Avoid Jinja2
default()with quoted strings directly invars:— 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.