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.

1
2
3
4
5
6
7
8
9
# roles/my_role/tasks/main.yml
- name: Set next page URL
  ansible.builtin.set_fact:
    next_url: "https://api.example.com/items?page=2"

# Later in the same play, if this role runs again:
- name: This will use the leftover next_url from last time
  ansible.builtin.uri:
    url: "{{ next_url }}"  # page=2, not page=1!

A Real Example: Pagination State

Here’s a pattern that breaks silently. You’re paginating an API and tracking the next page URL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- name: Build initial URL
  ansible.builtin.set_fact:
    api_url: >-
      {{ next_url if (next_url is defined and next_url)
         else 'https://api.example.com/items?limit=100&offset=0' }}

- name: Fetch page
  ansible.builtin.uri:
    url: "{{ api_url }}"
  register: response

- name: Set next URL if more pages
  ansible.builtin.set_fact:
    next_url: "{{ response.json.nextLink }}"
  when: response.json.nextLink is defined

- name: Clear next URL when done
  ansible.builtin.set_fact:
    next_url: ""
  when: response.json.nextLink is not defined

- name: Recurse if more pages
  ansible.builtin.include_tasks: paginate.yml
  when: next_url is defined and next_url != ""

This works correctly the first time. But if you loop this role over multiple environments:

1
2
3
4
5
6
7
8
- name: Sync all environments
  ansible.builtin.include_role:
    name: my_sync_role
  loop:
    - { env: "prod", vip: "api-prod.example.com" }
    - { env: "staging", vip: "api-staging.example.com" }
  loop_control:
    loop_var: environment

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:

1
2
3
4
5
6
7
8
9
# roles/my_sync_role/tasks/main.yml

- name: Reset pagination state
  ansible.builtin.set_fact:
    next_url: ""
    api_url: ""

- name: Run sync
  ansible.builtin.include_tasks: paginate.yml

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:

1
2
3
4
5
# Instead of:
next_url: "..."

# Use:
sync_role_next_url: "..."

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:

1
2
3
4
5
- name: Process items
  ansible.builtin.include_tasks: process.yml
  vars:
    batch_size: 250
    state_file: "/tmp/progress.json"

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:

1
2
3
4
5
6
7
8
9
- name: Fetch data
  ansible.builtin.uri:
    url: "https://api.example.com/items"
  register: api_response  # Clearly tied to this specific task

# vs:
- name: Save response
  ansible.builtin.set_fact:
    api_data: "{{ api_response.json }}"  # Now it floats in host scope forever

Checking What Facts Are Set

When debugging, dump all host facts to see what’s floating around:

1
ansible localhost -m ansible.builtin.debug -a "var=hostvars[inventory_hostname]"

Or add a debug task in your playbook:

1
2
3
4
- name: Show all facts
  ansible.builtin.debug:
    var: hostvars[inventory_hostname]
  tags: [never, debug_facts]

Run with -t debug_facts when you need it, otherwise it’s skipped.

Quick Reference

SituationSolution
Role runs multiple times in a playReset state vars at top of main.yml
Variable names colliding between rolesPrefix with role name
State only needed in one task fileUse vars: on include_tasks
Capturing task outputUse register instead of set_fact
Debugging unexpected variable valuesDump 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.