← All TILs · ansible

group_by: groups from facts, built during the run

ansible - 2026-10-03

Twenty-second entry in the Ansible inventory from scratch series. The earlier items gave each group a place in the inventory before the run: in a hosts file, or built by an inventory plugin from the data of another system. Some groups depend on what a host turns out to be: its distribution, its OS family. ansible.builtin.group_by is the module that builds those during a play, from facts, the values Ansible gathers from a host (see item 7). The previous entry used its sibling add_host, which adds hosts rather than groups. Everything below ran with ansible-core 2.21.4.

Facts from several distributions on one machine

The example's six hosts, web1, web2, db1, db2, lb1 and lb2, all run on the local machine, so gathering facts would give each the same distribution. Instead, the example uses recorded facts and a fact cache, the store where Ansible keeps facts between runs (see item 12): facts/ holds one file per host, in the format the jsonfile cache plugin writes, and run.sh copies them to the cache directory that ansible.cfg sets. With fact_caching_timeout = 0 they never expire, and with gather_facts: false the plays read them without connecting to anything. It's cheaper than one container per distribution, and the output is the same on every run.

Host distribution distribution_major_version os_family
web1 Ubuntu 24 Debian
web2 Debian 12 Debian
db1 Rocky 9 RedHat
db2 AlmaLinux 9 RedHat
lb1 openSUSE Leap 15 Suse
lb2 no facts

The values are the names setup, the module that gathers facts, reports for these distributions; ansible-core maps each to its family in module_utils/facts/system/distribution.py. The format of the cache files belongs to ansible-core (an s1_ prefix in 2.21), so the fixtures need re-recording when it changes.

Grouping by OS family

The hosts file only has web, db and lb. The playbook's first play runs on all hosts:

- name: Add each host to the group of its OS family
  ansible.builtin.group_by:
    key: os_{{ group_os_family }}
    parents: by_os

with group_os_family: "{{ ansible_facts.os_family | default('unknown') }}" in the play's variables.

A second task groups by distribution and major version, under the family: parents: os_{{ group_os_family }}, so db1 lands in rocky9, a child of os_RedHat. Each host's group_names, the variable that lists the groups it's in, after the two tasks:

db1: by_os db os_RedHat rocky9
lb1: by_os lb opensuse_leap15 os_Suse
lb2: by_os lb os_unknown unknown
web1: by_os os_Debian ubuntu24 web

A second play with hosts: os_RedHat then ran on db1 and db2. That's the pattern the Ansible docs give in Handling OS and distro differences for running across operating systems: one play that groups, then plays on the groups.

group_vars of a group that doesn't exist yet

group_vars/<group>/ holds a group's variables (see item 2), and nothing stops it from naming a group the hosts file lacks. The example's inventory has group_vars/os_Debian/, os_RedHat/ and os_Suse/, each setting package_manager to apt, dnf or zypper. A task before group_by and one after, in the same play, recorded it:

So the variables follow the group as soon as the host joins it. The docs' note on this pattern says all three names must match: the key, the play's hosts:, and the group_vars/ directory. A typo in one of them is silent: the group gets no variables, or the play matches no hosts.

Group names with spaces

distribution can contain a space: openSUSE Leap, Linux Mint. The module's documentation says spaces in group names become dashes. In ansible-core 2.21.4, pitfalls/spaces.yml found otherwise with key: distro_{{ ansible_facts.distribution }}:

Sanitise the value yourself: the example's key uses ansible_facts.distribution | regex_replace('\\W', '_') | lower, which replaces anything other than a letter, a digit or _ with _, and gives opensuse_leap15. That's also a name group_vars/ can hold and a pattern can match.

--limit and failed hosts

--limit keeps only the hosts of a run that match a pattern (see item 11).

pitfalls/no-default.yml uses key: os_{{ ansible_facts.os_family }} without the default. lb2's task failed, object of type 'dict' has no attribute 'os_family', and the run ended with exit code 2. lb2 joined no group, and the next play, on all, ran on the five other hosts only: a host that fails is left out of the rest of the run.

The groups end with the run

group_by changes the in-memory inventory, the copy ansible-playbook works from, never the inventory's files. After the run, ansible-inventory --graph showed no os_ group, and ansible-inventory --host db1 had no package_manager: the group_vars/os_RedHat/ directory applies to nobody until a play builds the group again.

When keyed_groups is the better tool

Item 11 built groups with the constructed inventory plugin's keyed_groups, and item 18 covered it in full. constructed builds its groups when the inventory is parsed, from variables the earlier sources set and from the fact cache. The example's keyed/constructed.yml:

plugin: ansible.builtin.constructed
strict: false
keyed_groups:
  - key: ansible_os_family
    prefix: os
    parent_group: by_os

The cached facts carry the ansible_ prefix, hence ansible_os_family. ansible-inventory -i inventory -i keyed/constructed.yml --graph by_os showed os_Debian, os_RedHat and os_Suse with their hosts before any play, and os_RedHat worked as a pattern, so as a --limit too. lb2, with no facts, is in none of them.

The example

The series' companion repository, abdelhousni/ansible-inventory-series, holds the inventory with its group_vars/ for the dynamic groups, the recorded facts, the playbook, both pitfalls and the constructed source. run.sh prints the facts, runs the playbook without and with each --limit, checks what's left after the run, runs the pitfalls and the constructed version. Its CI runs it on every push and compares the output with the expected one.

Sources

Created 2026-10-03T18:29:01+02:00 · Edit