Magic Variables
Magic variables are values Ansible sets automatically that describe the run itself — which host this task is on, what groups exist, who else is in the play — and they are how one host reaches across to another's data. hostvars, groups, group_names, inventory_hostname, and ansible_play_hosts are the ones you use constantly.
The signature move is a web host reading hostvars['db1.larkspur.io']['ansible_facts']['default_ipv4']['address'] to template the database's IP into its own config — without ever running a task on the database. That single expression is how a push-based, stateless tool assembles a topology: each host reads the others' live facts, and no shared store sits in the middle.
hostvarsdb1's IP, without running a task on it.groupsgroups['web'] is every web host, iterated into an upstream block.inventory_hostnameweb1.larkspur.io, templated to identify the box.inventory_hostname and the Short Form
inventory_hostname is the name this host has in the inventory — web1.larkspur.io — and it is the value you template into per-host config files and log lines to identify the box. It answers "who am I" for the current task. inventory_hostname_short is the part before the first dot, web1, useful when a config wants the bare hostname rather than the fully qualified name.
One distinction matters: inventory_hostname is the inventory name, which is not always the connection address. A host whose inventory entry sets ansible_host to a separate IP or DNS name is reached at ansible_host but named by inventory_hostname. Templating the wrong one — the inventory label where you needed the SSH target — points config at a name that may not resolve where the application runs.
hostvars — Reach Into Any Host's Data
hostvars is a dict keyed by every host's inventory name, exposing that host's variables and gathered facts. hostvars['db1.larkspur.io']['ansible_facts']['default_ipv4']['address'] lets a web host template the database's real IP into app.env — the canonical cross-host read. The web host never runs a task on db1; it simply reads db1's facts out of hostvars, which is populated for every host Ansible knows about in the run.
# templates/app.env.j2 — rendered on each web host
DATABASE_HOST={{ hostvars['db1.larkspur.io']['ansible_facts']['default_ipv4']['address'] }}
DATABASE_PORT=5432
BIND={{ ansible_facts['default_ipv4']['address'] }}:{{ larkspur_web_nginx_port }}
The catch is that db1's facts must actually be present — either db1 was gathered in this same run, or its facts are in the cache. Reach for a host that was never gathered and the hostvars lookup returns undefined, which is the exact problem fact caching in Topic 31 exists to solve. The cross-host read is the reason caching matters at all.
groups and group_names
These two are constantly confused, and keeping them straight is half of using magic variables well. groups is a dict of every group to its member hostnames: groups['web'] is the list of all web hosts, which you iterate to template "all the web servers" into an HAProxy backend or an nginx upstream block. It is the whole-inventory view — every group, every member.
group_names is the opposite direction: the list of groups this host belongs to. On web1 it reports ['web', 'larkspur'], and you use it in a when: to branch by role — when: "'web' in group_names" runs a task only on web hosts. Reaching for groups when you meant group_names — testing membership with the all-groups dict instead of this host's own list — is the classic mix-up, and it usually fails silently with the wrong branch taken.
ansible_play_hosts and ansible_play_batch
ansible_play_hosts is the set of hosts still active in the current play — those that haven't failed and dropped out. ansible_play_batch is the current serial batch, the wave of hosts being processed right now under a rolling configuration. Both shrink as hosts drop out, so they describe the live state of the play rather than its original target list.
These matter for rolling deploys, where you template "the hosts in this wave" into a load-balancer drain step or check who is left after some hosts failed. The trap is treating ansible_play_hosts as the full original target — it only contains hosts still active, so a host that failed earlier silently disappears from any list built from it. When you need the complete static target list regardless of failures, use groups, not ansible_play_hosts.
The Cross-Host Read in Practice
Larkspur's nginx upstream block iterates groups['web'] and resolves each member's IP through hostvars, producing a backend list of every web host's real address. The app's app.env reads db1's address the same way. This is how a push-based, stateless tool assembles a topology — each host reads the others' live facts at template time, and there is no service-discovery store or shared state to consult.
The pattern is worth naming because it replaces a whole category of infrastructure. Instead of Consul or a config server handing out peer addresses, the run gathers every host's facts and exposes them through hostvars, and a template stitches the topology together from facts that are true this run. The cost is that every host whose data you read must be gathered or cached — which is precisely why the next topic exists.
- Reading
hostvars['db1.larkspur.io']['ansible_facts'][...]for a host that wasn't in the play and whose facts weren't gathered or cached — the facts are absent and the template throws "undefined"; this is the exact problem fact caching solves. - Iterating
groups['web']and assuming the order is stable or sorted — group membership order follows inventory and isn't guaranteed; sort explicitly if order matters for an upstream block. - Confusing
groups(all groups to their hosts) withgroup_names(this host to its groups) — reaching forgroupsin awhen:to test "is this a web host" instead of'web' in group_names. - Templating
ansible_play_hostsexpecting the full original target list after some hosts failed — it only contains hosts still active, so a failed host silently drops out of any list built from it. - Using
inventory_hostnamewhere you meant the connection address — it is the inventory name, which may differ fromansible_host, the actual SSH target; templating the wrong one points config at the wrong box.
- Read another host's facts through
hostvars['<name>']['ansible_facts'][...], and ensure that host was gathered in the run or its facts are cached before you reach for them. - Build "all hosts in a tier" config — nginx upstreams, HAProxy backends — by iterating
groups['<group>']and resolving each member throughhostvars, instead of hardcoding peer addresses. - Branch on host role with
'<group>' in group_names, and test which hosts exist withgroups, keeping the two magic vars straight. - Use
ansible_play_hosts/ansible_play_batchonly when you specifically need the live, post-failure or per-batch host set — for the static target list, usegroups. - Sort an iterated
groups['<group>']explicitly when the rendered order matters, since inventory order is not guaranteed stable across runs.
salt['mine.get']
Consul · etcd back the same pattern in service-discovery stacks
Terraform cross-resource references from state, the stateful analog of hostvars
Knowledge Check
What does hostvars expose, and why does a web host use it to read db1's IP?
- A dict of every host's variables and gathered facts, so the web host reads
db1's facts without running any task ondb1 - A read-only copy of the control node's persisted state file on disk, which the web host then queries for
db1's stored address - Only the current host's own variables, so the web host must first SSH directly into
db1to populate it - The ordered list of hosts in the current play, from which the web host picks out
db1by its index
What is the difference between groups and group_names?
groupsmaps every group to its member hosts;group_namesis the list of groups the current host belongs togroupsis just the current host's own groups;group_namesis the list of every group defined in the inventory- They are simply two aliases for the very same dict, kept only for backward compatibility
groupslists only the groups active this run;group_nameslists every group ever defined
Why can ansible_play_hosts be shorter than the play's original target list?
- It holds only hosts still active — one that failed earlier drops out, so lists built from it silently lose that host
- It is capped at the configured
serialbatch size regardless of how many hosts are actually still running - It excludes any host whose facts were not successfully gathered this run, even when the host itself is healthy
- It lists only the hosts that belong to the first inventory group, quietly ignoring the rest of the play's original target
Why can a cross-host read of hostvars['db1...']['ansible_facts'] fail without fact caching?
- If
db1wasn't gathered in this run and isn't cached, its facts are absent and the lookup returns undefined hostvarsonly ever holds the current host's data, so any cross-host read needs caching to work at all- Cross-host reads are disabled by default, and only the fact cache is what enables the
hostvarsdict at all - Facts expire right after the first task unless caching keeps them alive long enough for the template step
You got correct