Facts or variables: what Ansible discovers (as-is) against what you declare (to-be)
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:
- discovered information grabbed from the existing environment, this is the "As-Is" information.
- managed information entered in a tool, expressing the state to be reached, hence the "To-Be" information.
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:
- Variables are what you write: in the inventory, in a playbook, on the command line. The inventory's are the to-be.
- Facts are what a module reports about a host, kept in the
ansible_factsvariable of that host. Thesetupmodule, run by a play'sgather_factsstep, gathers the general ones (operating system, addresses, memory); others come from modules likeansible.builtin.package_facts, which lists the installed packages inansible_facts.packages. Part 11 of Shaping data in Ansible reads another host's facts throughhostvars, the variable that maps each host name to its variables.
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_factsvariable, 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 inventory holds the to-be: what each host should be, written by hand. Facts are the as-is, reported by the host at run time.
- Compare them, don't merge them: a play reads both and reports or fixes the difference.
- Never copy a discovered value into
host_vars/: it stops being a declaration and hides the drift. - A fact cache makes facts show up in
ansible-inventory --host;--exportshows the host's own inventory variables only. - Read facts through
ansible_facts., and keep inventory variables off fact names, at least untilINJECT_FACTS_AS_VARSdefaults to false in 2.24.
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
- Red Hat Community of Practice, automation good practices,
inventories/README.adocat commitfb5fcd6(2026-09-09): Differentiate clearly between "As-Is" and "To-Be" information. - ansible-core 2.21.4:
config/base.yml(INJECT_FACTS_AS_VARS,CACHE_PLUGIN) andcli/inventory.py(_get_host_variables, with and without--export). - Ansible documentation: Understanding variable precedence and the ansible-core 2.20 porting guide (
INJECT_FACTS_AS_VARS). - Every result above came from ansible-core 2.21.4 and community.docker 5.3.0 on 2026-10-03, with Docker 29.6.2 and containers from
ubuntu:24.04anddebian:bookworm-slim, on the local machine.
Created 2026-10-03T10:04:28+02:00 · Edit