Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesRegistered variables

set_fact, register or inventory?

Advanced⏱ ~24 minansible-playbook

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

Not yet marked complete on this device.

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)registerset_fact
Set byA human, in a file, under reviewA task’s result, automaticallyA task, from an expression
PrecedenceEntries 3–10Entry 19Entry 19
ScopeThe host or group it namesThe host it ran onThe host it ran on
LifetimeEvery run, foreverThe rest of this runThe rest of this run, all plays
Visible in ansible-inventoryYesNoNo
Reviewable in a pull requestYesThe task is; the value is notThe 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:

Read-only / Safea registered variable is not a fact
- 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:

Read-only / Safea defensible set_fact
- 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:

ValueCould it be written down beforehand?Belongs in
app_port: 8080 via set_factYes. Somebody chose 8080.group_vars
Worker count from processor_vcpusNo. Depends on the host.set_fact
backup_target chosen by when: 'prod' in group_namesYes. That is a group decision.group_vars/prod.yml
Cluster leader = first host in a groupYes, but it depends on inventory orderingEither — 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:

Read-only / Safeone host computes, every host reads
- 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:

  1. Could the value have been written down beforehand? If yes, it is inventory pretending to be logic.
  2. Is the name prefixed? An unprefixed set_fact is a collision waiting for the run where two roles both use port.
  3. Does every host in the play get a value? If it is conditional, is there a layer underneath supplying a default?
  4. Does it override something? Search group_vars for the name. A set_fact shadowing an inventory variable is either a deliberate emergency override that should be commented, or a bug.
  5. Is cacheable: true there 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

  1. 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?

  2. Q2. Which of these is the strongest single test for whether a value belongs in set_fact rather than group_vars?

  3. Q3. What is lost when a decision that belongs in group_vars is implemented as a conditional set_fact instead? Select all that apply.

  4. 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.