Chapter 5: Variables & Facts
Topic 29

set_fact and Registered Variables

FactsWorkflow

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.

Three ways to capture a value during a run
register
Captures a task's result — return code, stdout, changed status — to branch on what it returned.
set_fact
Computes a new variable mid-run that persists for the rest of the run on that host.
facts.d
Custom facts living on the node — the box publishes its own metadata, picked up on every gather.

register — 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.

register a command, then branch on its return code
- 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 vs set_fact

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

Common Mistakes
  • Registering a variable inside a loop and then reading result.stdout as if it were a scalar — looped registration returns result.results, a list, and the scalar access is undefined.
  • Using set_fact to override a value also set in group_vars and forgetting it outranks the inventory file — the group var is now dead for the rest of the run and a reviewer editing group_vars sees no effect.
  • Expecting a plain set_fact to be visible in a later play of the same run — without cacheable: true it lives only for the current play's run on that host, and the next play sees nothing.
  • Writing a facts.d script 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.stdout when the task could fail without setting failed_when/ignore_errors — a failed registered task may not carry the field you expect, and the next when: throws on an undefined key.
Best Practices
  • Use register to capture and branch on task output, and always check result.rc/result.failed before reading result.stdout so a failed task doesn't blow up the next when:.
  • Use set_fact to 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.d when 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: true to a set_fact only when a later play genuinely needs the value, and keep facts.d scripts fast because they run on every gather.
  • When registering inside a loop, read result.results and iterate it, never result.stdout directly, so the looped shape doesn't fail undefined.
Comparable tools Chef 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?

  • register captures the result of running a task, while set_fact assigns a value you computed from existing vars and facts
  • register assigns a value you derived yourself, while set_fact captures a command's stdout and return code
  • They are fully interchangeable — both store a task's output, just under slightly different variable scopes
  • register writes its captured value straight to the persistent on-disk fact cache, while set_fact writes 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.stdout alone 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 inside ansible_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: true writes 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: play is 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_fact in every later play

You got correct