Chapter 5: Variables & Facts
Topic 30

Magic Variables

ConceptVariables

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.

Three magic variables you reach for constantly
hostvars
Any host's data — read another box's vars and facts, e.g. db1's IP, without running a task on it.
groups
The group-to-hosts map — groups['web'] is every web host, iterated into an upstream block.
inventory_hostname
This host's own inventory name — web1.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.

app.env on a web host, reading db1's live IP through hostvars
# 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.

Common Mistakes
  • 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) with group_names (this host to its groups) — reaching for groups in a when: to test "is this a web host" instead of 'web' in group_names.
  • Templating ansible_play_hosts expecting 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_hostname where you meant the connection address — it is the inventory name, which may differ from ansible_host, the actual SSH target; templating the wrong one points config at the wrong box.
Best Practices
  • 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 through hostvars, instead of hardcoding peer addresses.
  • Branch on host role with '<group>' in group_names, and test which hosts exist with groups, keeping the two magic vars straight.
  • Use ansible_play_hosts/ansible_play_batch only when you specifically need the live, post-failure or per-batch host set — for the static target list, use groups.
  • Sort an iterated groups['<group>'] explicitly when the rendered order matters, since inventory order is not guaranteed stable across runs.
Comparable tools Puppet exported resources and PuppetDB queries Salt mine and 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 on db1
  • 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 db1 to populate it
  • The ordered list of hosts in the current play, from which the web host picks out db1 by its index

What is the difference between groups and group_names?

  • groups maps every group to its member hosts; group_names is the list of groups the current host belongs to
  • groups is just the current host's own groups; group_names is the list of every group defined in the inventory
  • They are simply two aliases for the very same dict, kept only for backward compatibility
  • groups lists only the groups active this run; group_names lists 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 serial batch 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 db1 wasn't gathered in this run and isn't cached, its facts are absent and the lookup returns undefined
  • hostvars only 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 hostvars dict at all
  • Facts expire right after the first task unless caching keeps them alive long enough for the template step

You got correct