set_fact and Registered Variables
Gathered facts tell you what a host is; three other mechanisms let you compute and capture values during a run. register captures the output of any task — return code, stdout, changed status — into a variable. set_fact defines a computed variable that persists for the rest of the run on that host. And facts.d lets the node itself publish custom local facts that gathering picks up.
Together they cover three distinct needs: "I need the result of that command," "I need to derive a value once and reuse it," and "this box should advertise its own metadata." Larkspur uses all three — registering a health-check command's exit code, deriving an app_url from the port and hostname, and dropping a deploy-timestamp fact into facts.d so the box reports when it was last shipped.
registerset_factfacts.dregister — Capture a Task's Result
register: result stores a task's full return dictionary — result.rc for the return code, result.stdout and result.stdout_lines for output, result.changed and result.failed for status. The next task reads it, typically in a when: like when: result.rc != 0 or by templating result.stdout into a message. This is how you act on the output of a command, an API call, or a file check — run something, capture what it returned, branch on it.
- name: Check whether the app responds locally ansible.builtin.command: curl -fsS http://127.0.0.1:8000/health register: health failed_when: false - name: Restart gunicorn if the health check failed ansible.builtin.service: name: gunicorn state: restarted when: health.rc != 0
The first task captures the curl's exit code into health and uses failed_when: false so a non-zero code doesn't abort the play; the second reads health.rc to decide whether to restart. Without the register, the second task would have nothing to branch on — the command's result would have vanished the moment it finished.
set_fact — Define a Computed Variable
set_fact: app_url="https://{{ inventory_hostname }}:{{ larkspur_web_nginx_port }}" creates a variable on the host that every later task and template in the run can read. It persists for the rest of the run on that host, which makes it the standard way to derive a value once and reuse it — instead of repeating the same Jinja2 expression in five places, you compute app_url a single time and reference the name everywhere.
The difference from a play var is that a set_fact is produced by a task, so it can incorporate anything available at that point — gathered facts, a registered result, another variable. It is the right tool whenever a value must be derived rather than declared, and you want that derivation to happen once at a known point in the run rather than re-rendered lazily at every use site.
Where register and set_fact Sit in Precedence
Both set_fact and registered vars rank above play vars:, above role vars/, and above block and task vars: — sitting near the top, beaten only by role and include params and by -e — so they share the rung labeled "set_fact / registered vars" near the ceiling of the precedence ladder. The practical consequence is that a set_fact overrides an inventory value mid-run. That is intended for computed overrides, but it surprises anyone who also set the same name in group_vars: the group value is now dead for the rest of the run, and a reviewer editing the group file sees no effect.
This is why naming matters here. A set_fact that accidentally collides with a group_vars name silently shadows it, and because the shadow happens at run time rather than parse time, nothing warns you. Treat the names you compute as part of the same flat namespace as everything in inventory, and the precedence rank stops being a foot-gun.
Custom Facts via facts.d
Drop an executable script, or an INI or JSON file, into /etc/ansible/facts.d/<name>.fact on the managed node, and its output appears under ansible_facts['ansible_local'][<name>] on every gather. This lets a box advertise its own metadata — a deploy timestamp, a hardware tag, a build number — so the node remains the source of truth even for facts Ansible can't natively discover. The script runs as part of fact gathering, and whatever it prints becomes readable structured data.
This is the spine again, extended: rather than a control-node database recording when each host was deployed, the host itself carries that fact and reports it live. A web1 rebuilt from scratch reports no deploy marker until one is written, which is correct — the absence is the truth. Custom facts keep the node authoritative for metadata that has no native fact, without introducing a stored record anywhere else.
Loops, until, and Persistence Nuances
Registering inside a loop changes the shape of the result: instead of a single dict, you get result.results, a list with one entry per iteration. Reading result.stdout on a looped register is undefined — you must iterate result.results and read each entry's stdout. This catches everyone once, because the same register keyword produces a scalar dict outside a loop and a list inside one.
Persistence has its own nuance. A plain set_fact lives only for the current run on that host and is gone by the next play of a different run. Adding cacheable: true additionally writes the value into the fact cache covered in Topic 31, so it survives into other plays and later runs that read the cache. Use the flag only when a later play genuinely needs the value; a cacheable fact that nobody reads later is just cache churn.
register — captures the result of running a task: its return code, stdout, and changed status. It exists because you ran something and need to react to what it returned. Reach for it to branch on a command's exit code, parse an API response, or check a file probe.
set_fact — assigns a value you computed from existing variables and facts. It exists because you derived something and want to name it once and reuse it. Reach for it to build an app_url from a host and port, or to compute a path once for every later task to read.
- Registering a variable inside a
loopand then readingresult.stdoutas if it were a scalar — looped registration returnsresult.results, a list, and the scalar access is undefined. - Using
set_factto override a value also set ingroup_varsand forgetting it outranks the inventory file — the group var is now dead for the rest of the run and a reviewer editinggroup_varssees no effect. - Expecting a plain
set_factto be visible in a later play of the same run — withoutcacheable: trueit lives only for the current play's run on that host, and the next play sees nothing. - Writing a
facts.dscript that is slow or hangs — it runs on every single gather, so a 5-second custom fact adds 5 seconds to every play against that host. - Reading
result.stdoutwhen the task could fail without settingfailed_when/ignore_errors— a failed registered task may not carry the field you expect, and the nextwhen:throws on an undefined key.
- Use
registerto capture and branch on task output, and always checkresult.rc/result.failedbefore readingresult.stdoutso a failed task doesn't blow up the nextwhen:. - Use
set_factto compute a value once —app_url, a derived path — and reference it everywhere, rather than repeating the same Jinja2 expression across tasks. - Reach for
facts.dwhen the node should advertise metadata Ansible can't natively gather — a deploy marker, a local hardware tag — keeping the node the source of truth. - Add
cacheable: trueto aset_factonly when a later play genuinely needs the value, and keepfacts.dscripts fast because they run on every gather. - When registering inside a loop, read
result.resultsand iterate it, neverresult.stdoutdirectly, so the looped shape doesn't fail undefined.
node.run_state and Ohai custom plugins
Puppet external facts — facts.d is the same idea and name
Salt grains modules
Terraform local values, the rough analog of set_fact
Knowledge Check
What is the difference between what register and set_fact capture?
registercaptures the result of running a task, whileset_factassigns a value you computed from existing vars and factsregisterassigns a value you derived yourself, whileset_factcaptures a command's stdout and return code- They are fully interchangeable — both store a task's output, just under slightly different variable scopes
registerwrites its captured value straight to the persistent on-disk fact cache, whileset_factwrites only to in-run memory
You register a variable inside a loop. How do you read the per-iteration output?
- Iterate
result.results, a list with one entry per loop pass —result.stdoutalone is undefined for a looped register - Read top-level
result.stdout, which holds the concatenated output of every single loop iteration joined together in pass order - Read
result.last, which Ansible automatically sets to the final loop iteration's full result dict for you - You can't — registering inside a loop quietly discards every pass except the first iteration
Where does a custom fact written via facts.d appear?
- Under
ansible_facts['ansible_local'][<name>]on every gather of that host - Under
ansible_facts['custom'][<name>], but merged in only when fact caching is enabled - In a control-node database that the next run reads the value back from
- As a bare top-level
ansible_<name>variable, never nested insideansible_facts
Why is a plain set_fact not visible in a later play of the same run, and what changes that?
- It lives only for the current play on that host; adding
cacheable: truewrites it to the fact cache so a later play can read it - It is fully global across the entire run by default, and adding an explicit
scope: playis what then restricts it down to just one play - It is already visible to every later play in the run; only registered vars are the ones that stay strictly play-scoped
- It expires right after one task unless you manually re-run the
set_factin every later play
You got correct