AnsibleXIV · Facts and Registered VariablesRegistered variables
set_fact, register or inventory?
What you'll learn
- Place set_fact and registered variables on the precedence ladder
- Describe the host scope and lifetime of each of the three
- Recognise a set_fact that is really an undocumented policy decision
- Refactor a computed value into inventory where inventory is the right owner
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
You need a value during a run. You have three places to put it, and the mechanical differences between them are small enough that people choose by habit. The consequences are not small.
This lesson closes Part XIV by putting the runtime sources — facts,
register, set_fact — next to the inventory sources from Part XIII,
and giving you a test for which one a given value belongs in.
The three, compared
Inventory (group_vars / host_vars) | register | set_fact | |
|---|---|---|---|
| Set by | A human, in a file, under review | A task’s result, automatically | A task, from an expression |
| Precedence | Entries 3–10 | Entry 19 | Entry 19 |
| Scope | The host or group it names | The host it ran on | The host it ran on |
| Lifetime | Every run, forever | The rest of this run | The rest of this run, all plays |
Visible in ansible-inventory | Yes | No | No |
| Reviewable in a pull request | Yes | The task is; the value is not | The expression is; the value is not |
Two rows carry most of the weight.
Precedence. register and set_fact share entry 19, above every
inventory source and above play vars. A set_fact silently overrides
your group_vars for the rest of the run, and nothing reports that it
did. That is enormous power sitting in a task that looks like an
assignment.
Reviewable. The inventory row is the only one where a reviewer sees the value. For the other two, a reviewer sees an expression and has to simulate it in their head against a host they cannot see.
Where each one is right
register: capturing what a task did
register has no design question attached. If you need to know what a
task returned, you register it. The only judgement is whether you
needed to run that task at all.
The trap here is the one from the previous lesson wearing different clothes:
- name: Read the deployed version
ansible.builtin.slurp:
src: /opt/app/VERSION
register: version_file
- name: Use it
ansible.builtin.debug:
msg: "running {{ version_file.content | b64decode | trim }}"That works on the host the task ran on, in the plays that follow it, in
this run. It is not cached, it is not in ansible-inventory, and a
different playbook run five minutes later knows nothing about it.
set_fact: genuinely deriving something
The legitimate use is a value that is a function of other values, where writing the function once is better than repeating it:
- name: Derive the worker count from CPUs and the policy ratio
ansible.builtin.set_fact:
nginx_effective_workers: >-
{{ [ (ansible_facts.processor_vcpus * nginx_workers_per_cpu) | int,
nginx_workers_max ] | min }}
- name: Render the configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'Three properties make that defensible:
It is derived. processor_vcpus is an observation the controller
cannot know in advance, so the value genuinely cannot be written into
group_vars.
The policy inputs are in inventory. nginx_workers_per_cpu and
nginx_workers_max are decisions, and they live where decisions live.
A reviewer can change the policy without reading the arithmetic.
The name declares it. nginx_effective_workers reads as computed.
A later reader searching group_vars for it will not find it, and the
name is a hint about where to look instead.
Inventory: everything else
If the value is the same on every run, and a person decided it, it
belongs in group_vars or host_vars. That is the layering rule from
Part XIII and nothing in this part changes it.
The test
One question separates the two cases, and it is worth asking out loud:
Could this value have been written down before the run started?
If yes, it is a decision, and it belongs in inventory.
If no — it depends on something only the target host knows — a
set_fact is doing real work.
Applying it to four values a real repository contained:
| Value | Could it be written down beforehand? | Belongs in |
|---|---|---|
app_port: 8080 via set_fact | Yes. Somebody chose 8080. | group_vars |
Worker count from processor_vcpus | No. Depends on the host. | set_fact |
backup_target chosen by when: 'prod' in group_names | Yes. That is a group decision. | group_vars/prod.yml |
| Cluster leader = first host in a group | Yes, but it depends on inventory ordering | Either — see below |
The fourth is the honest edge case. A leader election from
groups['db'][0] is derived from inventory, so it is reproducible, and
it is also fragile in a way that is not obvious: adding a host to the
top of a group changes who the leader is, silently. Making it explicit
in inventory (db_primary: db01) trades a little duplication for a
decision a reviewer can see. Both are defensible; only one of them
fails safely when somebody edits the inventory alphabetically.
Scope: per host, and that surprises people
set_fact sets the value for the host the task ran on. A play over
forty hosts running a set_fact sets forty independent values.
That is usually what you want and occasionally the opposite:
- name: Compute the release tag once, on one host
ansible.builtin.set_fact:
release_tag: "{{ lookup('pipe', 'date -u +%Y%m%dT%H%M%SZ') }}"
run_once: true
delegate_to: localhost
- name: Every host uses the same tag
ansible.builtin.debug:
msg: "deploying {{ hostvars['localhost'].release_tag }}"Without run_once, each host computes its own timestamp and forty
hosts get forty different release tags — a bug that is invisible until
you try to correlate them afterwards.
With run_once and delegate_to, the fact belongs to the delegate.
Other hosts read it through hostvars, which means the plumbing is
explicit. Delegation has a full part later; the fact-scope consequence
belongs here because this is where people first meet it.
A checklist for review
When you meet a set_fact in someone’s play:
- Could the value have been written down beforehand? If yes, it is inventory pretending to be logic.
- Is the name prefixed? An unprefixed
set_factis a collision waiting for the run where two roles both useport. - Does every host in the play get a value? If it is conditional, is there a layer underneath supplying a default?
- Does it override something? Search
group_varsfor the name. Aset_factshadowing an inventory variable is either a deliberate emergency override that should be commented, or a bug. - Is
cacheable: truethere for a reason? If so, does the author know the precedence changes between run one and run two?
Five questions, about thirty seconds, and they catch the two failure modes — the hidden policy decision and the silent override — that otherwise show up as a wrong value in production with no error to trace.
Knowledge check
Knowledge check · 4 questions
Q1. A play sets db_host with set_fact based on group membership. An operator adds db_host to group_vars/production.yml to change it. What happens?
Q2. Which of these is the strongest single test for whether a value belongs in set_fact rather than group_vars?
Q3. What is lost when a decision that belongs in group_vars is implemented as a conditional set_fact instead? Select all that apply.
Q4. A set_fact executed under run_once with delegate_to: localhost is available to every host in the play under its own name.
Passing score: 75%. Answers are checked in this browser.