You set a variable with set_fact inside a role. It works perfectly. Then you run the same role again — either in a loop, or later in the same playbook — and it behaves differently. The variable has a value from the previous run that you didn’t expect.
This is one of Ansible’s most common gotchas. Here’s exactly why it happens and how to fix it.
Why set_fact Persists
In Ansible, set_fact sets a variable at the host level for the duration of the play. It is not scoped to the role or task file that created it. Once set, it stays set until:
- The play ends
- You explicitly overwrite it
- You explicitly clear it
This means if you use set_fact to track state inside a role — a pagination cursor, a collected list, a flag — that state carries over to the next time the role runs in the same play.
| |
A Real Example: Pagination State
Here’s a pattern that breaks silently. You’re paginating an API and tracking the next page URL:
| |
This works correctly the first time. But if you loop this role over multiple environments:
| |
The second iteration starts with whatever next_url was left over from the first one. If the first run ended cleanly with next_url: "", you’re fine. If it errored mid-pagination, the second run starts from the middle of the first run’s page sequence.
Fix 1: Explicit Reset at Role Entry
The cleanest fix is to reset your state variables at the top of the role’s main.yml before any logic runs:
| |
This makes the state lifecycle explicit and visible. Anyone reading the role can see exactly what variables it manages.
Fix 2: Namespace Your Variables
Generic names like next_url or api_token collide easily. Prefix them with the role name:
| |
This won’t prevent persistence, but it prevents accidental collision with variables from other roles.
Fix 3: Use vars Instead of set_fact for Role-Internal State
If a variable only needs to exist within a single task file (not shared across included tasks), use vars: on the task instead of set_fact:
| |
Variables set this way are scoped to the include_tasks call and don’t pollute the host’s fact namespace.
Fix 4: Register Instead of set_fact When Possible
If you’re capturing output from a task, register is always preferable to set_fact for intermediate results. Registered variables are still host-scoped, but the intent is clearer and they’re harder to accidentally reuse:
| |
Checking What Facts Are Set
When debugging, dump all host facts to see what’s floating around:
| |
Or add a debug task in your playbook:
| |
Run with -t debug_facts when you need it, otherwise it’s skipped.
Quick Reference
| Situation | Solution |
|---|---|
| Role runs multiple times in a play | Reset state vars at top of main.yml |
| Variable names colliding between roles | Prefix with role name |
| State only needed in one task file | Use vars: on include_tasks |
| Capturing task output | Use register instead of set_fact |
| Debugging unexpected variable values | Dump hostvars[inventory_hostname] |
The root rule: set_fact is play-scoped, not role-scoped. Design your roles accordingly, and always reset state you own at the entry point.