← All TILs · ansible

Facts or variables: what Ansible discovers (as-is) against what you declare (to-be)

ansible - 2026-10-03

Seventh entry in the Ansible inventory from scratch series. The entries so far put variables in the inventory: item 2 in group_vars/, item 4 connection settings in host_vars/. Ansible also learns things about each host by itself, its facts. This entry looks at the difference between the two, with a PostgreSQL version that drifted, and at which of them belongs in the inventory. Everything below ran with ansible-core 2.21.4 and community.docker 5.3.0, on 2026-10-03.

As-is and to-be

The Red Hat Community of Practice's automation good practices, a guide written by Red Hat consultants, has a rule on this in its inventory chapter, Differentiate clearly between "As-Is" and "To-Be" information:

As you combine multiple sources, some will represent:

In general, the focus of an inventory is on the managed information because it represents the desired state you want to reach with your automation. This said, some discovered information is required for the automation to work.

and gives the reason:

Mixing up these two kind of information can lead to your automation taking the wrong course of action by thinking that the current situation is aligned with the desired state. That can make your automation go awry and your automation engineers confused. There is a reason why Ansible makes the difference between "facts" (As-Is) and "variables" (To-Be), and so should you. In the end, automation is making sure that the As-Is situation complies to the To-Be description.

In Ansible's terms:

Declared against installed

The example's inventory has one group, postgresql, with two hosts, both containers reached with the community.docker.docker connection from item 4. The group declares the major version its hosts should run:

# group_vars/postgresql/postgresql.yml
postgresql_version: 16

db1 is an Ubuntu 24.04 container with the postgresql-client-16 package; db2 a Debian 12 container with postgresql-client-15. A playbook runs package_facts on both, takes the major version from the name of the installed postgresql-client-NN package, and compares it with postgresql_version:

- name: Gather the installed packages
  ansible.builtin.package_facts:
{% set declared = hostvars[host].postgresql_version | string %}
{% set installed = hostvars[host].ansible_facts.packages
     | select('match', '^postgresql-client-[0-9]+$')
     | map('regex_replace', '^postgresql-client-', '') | list %}

It wrote:

db1: declared 16, installed 16: ok
db2: declared 16, installed 15: drift

The declaration says what db2 should be; the fact says what it is; the difference is the work left to do. On Debian and Ubuntu, package_facts needs the python3-apt package on the host: the first images had none, and the module failed with "Could not detect a supported package manager".

Don't write the discovered value back

Say someone, seeing db2 runs 15, "fixes" the inventory to match:

# host_vars/db2/postgresql.yml
postgresql_version: 15

A host variable overrides a group variable, so db2's declaration is now 15. The same playbook, with this file added, wrote:

db1: declared 16, installed 16: ok
db2: declared 15, installed 15: ok

Nothing changed on db2, and the drift is gone from the report. The variable is no longer a declaration: it describes what db2 was when someone looked, and it stays that way after an upgrade, or a failed one. The guide notes the same of configuration management databases (CMDBs), the inventories of record many companies keep: "many CMDBs have failed because they don't respect this principle." When a host must stay on another version for a while, that's a decision, and it can go in host_vars/ with a comment saying why; a value copied from the host isn't.

Where facts live: not in the inventory, but close

Facts don't come from the inventory. Before any play, ansible-inventory --host db1, which prints the variables a host gets, listed:

ansible_connection, ansible_host, ansible_python_interpreter, postgresql_version

By default facts last only as long as the run. A fact cache keeps them between runs; the example's ansible.cfg turned one on, a JSON file per host:

[defaults]
fact_caching = ansible.builtin.jsonfile
fact_caching_connection = out/facts

After the playbook ran, the same command listed:

ansible_connection, ansible_host, ansible_python_interpreter, packages, postgresql_version

packages, a fact, now looked like one of db1's inventory variables. ansible-core's cli/inventory.py explains it: without --export, --host asks for "all vars flattened by host", which includes cached facts. With --export, which only reads variables "defined directly" on the host and from vars plugins, it listed:

ansible_connection, ansible_host, ansible_python_interpreter

No facts, and no postgresql_version either: --export leaves out the group's variables too. So with a fact cache, check what ansible-inventory --host shows before taking it for the inventory's content.

When a fact and a variable share a name

packages appeared without its ansible_facts. prefix because of INJECT_FACTS_AS_VARS. Its description in ansible-core's config/base.yml:

Facts are available inside the ansible_facts variable, this setting also pushes them as their own vars in the main namespace.

It's on by default, and the ansible-core 2.20 porting guide deprecated that: "it will switch to False in Ansible 2.24."

In Ansible's variable precedence, "Host facts and cached set_facts" come after, so override, "Inventory host_vars/"* and every inventory group variable. An injected fact therefore wins over an inventory variable of the same name. The example declared a list in group_vars/postgresql/packages.yml:

packages:
  - postgresql-client-16

and printed its type after package_facts:

injected (the default): packages is a dict
warning: INJECT_FACTS_AS_VARS default to `True` is deprecated
inject_facts_as_vars=false: packages is a list

With injection on, the declared list was replaced by the fact's dict of installed packages, the as-is overwriting the to-be under the same name, and ansible-core 2.21.4 printed the deprecation warning. With ANSIBLE_INJECT_FACT_VARS=false, the inventory's list stayed. Reading facts as ansible_facts.packages, never as packages, works the same with both settings.

In short

The example repository

The series' companion repository, abdelhousni/ansible-inventory-series, holds this inventory, the two images behind db1 and db2, pinned by digest, and the two wrong inventories, each added as a second -i source. run.sh needs Docker: it starts both containers, runs the drift report, prints ansible-inventory --host before and after the fact cache fills, then repeats the two mistakes above. Its CI runs it on every push and compares the output with the expected one.

Sources

Created 2026-10-03T10:04:28+02:00 · Edit