skip to content

In Ansible, when do you reach for set_fact versus register versus vars_files, and what is the scope and lifetime of a variable created by set_fact?

level: middleimportance: should knowfreq 55%

answer

  1. one records a result, one assigns a value
  2. facts belong to a host, not the run
  3. survives later plays, not the next run
  4. cacheable writes it to the fact cache
  5. a loop turns the result into .results

basics

~20 s

register captures a task's result object for the host that ran it; set_fact computes a named value per host at run time; vars_files loads static YAML at the start of a play. A set_fact value is per host and survives into later plays of the same run, but not into the next run unless cacheable is set.

solid answer

~50 s

`register` is not really a variable-setting tool — it stores the whole result dictionary of a task (`rc`, `stdout`, `changed`, `failed`, and module-specific keys) under a name, per host, so a later `when` or expression can read it. `set_fact` is the deliberate assignment: you compute something — often from a registered result — and give it a clean name. `vars_files` is the static end: YAML files loaded once at play start, useful for shared configuration that does not depend on anything discovered at run time. Scope matters most in interviews: `set_fact` values are stored against the individual host, not globally, so setting one while looping over web1 does not make it visible to web2. They persist for the remainder of the playbook run, including later plays, and with `cacheable: yes` they are also written into the fact cache and survive into future runs. In precedence, `set_fact` and registered vars sit near the top — above play vars, `vars_files` and role vars.

code

yaml · 17 lines
yaml
- hosts: web
  vars_files:
    - vars/common.yml
  tasks:
    - name: Discover the running version
      ansible.builtin.command: cat /srv/app/VERSION
      register: version_out
      changed_when: false

    - name: Give the derived value a clean name
      ansible.builtin.set_fact:
        app_version: "{{ version_out.stdout | trim }}"
        cacheable: yes

    - name: Read another host's fact
      ansible.builtin.debug:
        msg: "db1 runs {{ hostvars['db1'].app_version | default('unknown') }}"

go deeper

for a junior

Know that register captures what a task returned and that you read fields such as .stdout from it, while set_fact assigns a value you compute yourself.

for a middle

Explain that facts are stored per host, that set_fact survives into later plays of the same run, and that the registered object becomes a .results list when the task loops.

for a senior

Show the operational judgment: when caching a fact with cacheable is worth the staleness, when to use delegate_facts, and why accumulating lists with set_fact in a loop should be a filter expression instead.

for a principal

Own how state moves between plays and runs across the estate — what is allowed to be discovered at run time versus declared in inventory, and how cached facts affect reproducibility of a replayed run.

## Three different jobs Candidates often treat these as three syntaxes for the same thing. They are not. **`vars_files`** is a play keyword that loads YAML files at play start: ```yaml - hosts: web vars_files: - vars/common.yml - "vars/{{ env }}.yml" tasks: [] ``` Everything in those files is available for the whole play. The filename itself may be templated, but only from variables already known when the play begins — you cannot use `vars_files` to load something based on a fact you have not gathered yet in this play. For that, `include_vars` is the task-time equivalent, and it sits higher in precedence. **`register`** attaches the *result object* of a task to a name, per host: ```yaml - name: Read the deployed version ansible.builtin.command: cat /srv/app/VERSION register: version_out changed_when: false - ansible.builtin.debug: msg: "{{ version_out.stdout }}" ``` What you get is a dictionary, not a string: `rc`, `stdout`, `stdout_lines`, `stderr`, `changed`, `failed`, plus module-specific fields. When the task has a `loop`, you get `version_out.results`, a list with one entry per iteration — forgetting that is a very common bug. A registered variable is always set, even when the task is skipped; a skipped task's result carries `skipped: true` and no `stdout`, which is why defensive code writes `version_out.stdout | default('')`. **`set_fact`** is explicit assignment at task time: ```yaml - name: Name the thing properly ansible.builtin.set_fact: app_version: "{{ version_out.stdout | trim }}" is_stale: "{{ version_out.stdout | trim != desired_version }}" ``` This is where derived values belong. It also solves a real problem: unlike a `vars:` entry, which is a lazily-evaluated Jinja expression re-rendered every time it is referenced, `set_fact` freezes a computed value once, at the moment the task runs. ## Scope and lifetime — the part that gets asked A `set_fact` value is stored against the **host**, exactly like a gathered fact. Three consequences: 1. **It is not global.** If the task ran only on `web1` (because of a `when`, a `run_once`, or a failure elsewhere), only `web1` has the value. Other hosts referencing it get an undefined-variable error. To read one host's fact from another host you go through `hostvars['web1'].app_version`. 2. **It persists across plays in the same run.** Set it in play one, read it in play three, for the same host. This is the sanctioned way to hand information between plays. 3. **It does not survive the process.** The next `ansible-playbook` invocation starts clean — unless you pass `cacheable: yes`, which additionally writes the value into the configured fact cache (`fact_caching = jsonfile` or `redis` in `ansible.cfg`), where it will be read back on subsequent runs. There is a subtlety in precedence between those two forms. A `set_fact` executed in the current run sits near the top of the ladder (level 19 of 22), above play vars and role vars. A value restored from the fact cache is treated as a *host fact* and sits much lower (level 11), below play vars. So a value can win today and lose tomorrow depending on whether it was just computed or read from cache — worth stating explicitly if the interviewer probes. A related keyword is `delegate_facts: true`, which stores the fact against the *delegated* host rather than the current one — used when a task runs somewhere else (a bastion, a load balancer) but the resulting information logically belongs to that other host. And `meta: clear_facts` discards gathered and set facts mid-run, which is occasionally needed after an action that invalidates them, such as rebooting into a new kernel. ## Choosing between them - Static configuration known before the run: `defaults/main.yml`, `group_vars`, or `vars_files`. - The outcome of a command or module: `register`, then read the fields you need. - Anything derived, conditional or computed that more than one later task will use: `set_fact`, once, with a clear name. - A value that must cross plays for one host: `set_fact`. - A value that must cross runs: `set_fact` with `cacheable: yes`, plus fact caching configured — and be honest about the staleness that introduces. The anti-pattern to name is using `set_fact` inside a loop to accumulate a list by re-assigning `mylist: "{{ mylist + [item] }}"`. It works, but it is slow and unreadable at scale; Jinja filters such as `map`, `select` and `zip` usually express the same transformation in one expression.

  • A task with a loop is registered — why does reading .stdout from it fail?
    Because a looped task registers a container with a `results` list, one entry per iteration, rather than a single result. You read `myvar.results[0].stdout`, or iterate `myvar.results` in a later loop. The top-level object still carries aggregate keys such as `changed`, but no `stdout` of its own.
  • Does set_fact on one host make the value available to the other hosts in the play?
    No. Facts are stored per host, so only hosts that actually executed the task hold the value. Other hosts must reach it through `hostvars['thehost'].name`, and that only works if the owning host really ran the task in this run — a `when` that skipped it leaves the name undefined.
  • What changes about precedence when a set_fact value comes back from the fact cache?
    It drops. A set_fact executed in the current run sits near the top of the precedence ladder, above play and role vars; the same value restored from the cache is treated as a host fact and sits below play vars. So play-level variables that lost to it today can win against the cached copy tomorrow.

saying these in an interview costs you the question

  • Thinks set_fact sets a value for all hosts at once
  • Expects a registered variable to be a plain string
  • Believes set_fact persists across runs by default
  • Assumes a skipped task leaves its registered variable undefined
  • Uses vars_files for something discovered during the run

context