An Ansible group is a named set of hosts in an inventory: a [webservers] header in INI, or a key holding hosts: in YAML. Plays target a group by name, variables set on the group reach every member, groups nest through children, and one host can belong to many groups. all and ungrouped always exist.
Inventory is where an Ansible estate is either legible or not. It answers two questions that every run depends on: which machines exist, and what is true about each of them. Get the structure right and playbooks stay short, because a play can name a group and inherit everything else. Get it wrong and the playbooks fill up with conditionals that test hostnames, which is the classic sign that inventory is doing too little work.
How to define groups in INI and YAML
This INI file, laid out as in the inventory guide, defines two function groups, a parent built from them with the :children suffix, and a variable on that parent:
[webservers]
web01.example.com
web02.example.com ansible_port=2222
[dbservers]
db01.example.com
[production:children]
webservers
dbservers
[production:vars]
ntp_server=ntp.prod.example.com
The YAML equivalent uses children:, hosts: and vars: keys:
production:
children:
webservers:
hosts:
web01.example.com:
web02.example.com:
ansible_port: 2222
dbservers:
hosts:
db01.example.com:
vars:
ntp_server: ntp.prod.example.com
- A host in a child group is automatically in every parent, so
web01.example.combelongs towebservers,productionandall. Groups can have several parents but no circular relationships, and a host in many groups is still processed once, with data merged from all of them. - Group names follow the rules for valid variable names: letters, numbers and underscores, not starting with a number. Write
eu_west, noteu-west. - In INI, host-line values are parsed as Python literals but
:varsvalues are always strings, soenabled=FALSEarrives as the string"FALSE". The docs recommend YAML inventory when types matter.
Formats and sources
An inventory can be written in INI or YAML. Both express the same thing: hosts, groups, and variables attached to either. Without -i, Ansible reads /etc/ansible/hosts. A single -i flag points at one source, the flag can be repeated, and it also accepts a directory, in which case eligible inventory sources are loaded and merged. Ansible ignores some directories and file extensions by default; check INVENTORY_IGNORE_PATTERNS and INVENTORY_IGNORE_EXTS if an expected source is missing. A directory can combine a dynamic cloud source for machines that come and go with a small static file for fixed hosts and group definitions.
When sources overlap, the last definition wins: -i staging -i production gives a shared variable its production value, and a directory’s sources merge in alphabetical filename order, so 01- and 02- prefixes set precedence.
Two groups always exist without being declared. all contains every host in the inventory. ungrouped contains every host that belongs to no other group, which makes it a useful audit target: hosts appearing there are usually the ones nobody has classified yet.
Host ranges avoid repetition. A pattern like web[01:50].example.com in an inventory file expands to fifty hosts, web[01:50:2].example.com takes every second number, and db-[a:f].example.com expands alphabetically. Nesting is expressed with children, so a production group can contain web, db and cache without listing a single host itself.
Connection details belong in inventory, not in plays
Behavioural inventory parameters tell Ansible how to reach a host, and they are ordinary variables:
ansible_hostwhen the inventory name is not resolvable, or when a machine is reached through a different address than it is called by.ansible_portfor a non-standard SSH port.ansible_userfor the login account.ansible_connectionto select a connection plugin, such aslocalfor the control node.ansible_python_interpreterwhen discovery picks the wrong interpreter, which is common on minimal or mixed-distribution images.
Putting these in inventory rather than in the play keeps the play portable. A play that hardcodes a user cannot be reused against an estate with a different convention.
group_vars and host_vars
Variables can be written inline in the inventory, but past a handful they belong in group_vars/ and host_vars/ directories placed next to the inventory file or next to the playbook. A file named group_vars/webservers.yml applies to every host in webservers; host_vars/web01.example.com.yml applies to one machine. Either can be a directory instead of a file, so group_vars/webservers/ holding tuning.yml and packages.yml splits a large set of variables into readable pieces. Files may end in .yml, .yaml, .json or nothing, but must be YAML, and files in a group directory load in lexicographical order. For keeping secrets in that same directory, see the vars and vault split.
The convention that scales is to keep defaults in group_vars/all, override them in progressively more specific groups, and use host_vars only for genuine per-machine facts such as an address or a disk layout. A host_vars file full of settings that ought to describe a role is a sign that a group is missing.
How variables merge, and the rule that catches people
Before a play runs, Ansible flattens variables down to each host. The documented order of precedence for inventory entities, lowest to highest, is: all, then the parent group, then the child group, then the host. More specific wins, which is what most people expect.
The part that is not obvious concerns groups at the same level. Ansible merges same-level groups in alphabetical order, and variables from the last group loaded overwrite those from earlier ones. If a_group and b_group both set testvar, the host ends up with b_group’s value purely because of the letter it starts with. That is a fragile thing to depend on, and renaming a group can silently change a value.
The documented fix is ansible_group_priority. It overrides alphabetical sorting for groups at the same level after the parent and child order is resolved. A larger number means the group is merged later and therefore wins, and the default is 1 when it is not set. Two constraints are worth memorising: it only affects groups at the same level, and it can only be set in an inventory source, not in a group_vars/ file, because Ansible reads it while loading group_vars/.
ansible_group_priority example and the depth rule
The documentation’s example, written as INI:
[a_group]
host1
[b_group]
host1
[a_group:vars]
testvar=a
ansible_group_priority=10
[b_group:vars]
testvar=b
Without the priority line host1 gets testvar=b; with it, a. :vars values are strings, but ansible-core’s group loader runs this one through int(), so 10 works.
The order comes from one line in the inventory helpers: a host’s groups are sorted by (depth, priority, name) and merged in that sequence, last one winning. Depth, the distance below all, sorts first, so a child always overrides its parent whatever the parent’s priority: ansible_group_priority=100 on production never beats webservers. Priority only settles groups at the same depth, and the name settles the rest.
There is one more default to know about. Duplicate dictionary variables are overwritten rather than merged. The configuration setting that changes this exists but is a global behaviour change with wide blast radius; the safer pattern is to leave it alone and merge explicitly in a template or task with the combine filter, so the merge is visible where it happens.
Command-line --extra-vars sit above all of this and override everything, which makes them useful for a one-off and dangerous as a habit. The full flag set is covered in ansible-playbook options.
Where inventory variables sit in precedence
The variable precedence list runs from 1 (command-line values) to 22 (extra vars). Inventory fills positions 3 to 10, and higher wins:
| Position | Source |
|---|---|
| 3 | Group vars written in the inventory file, such as [webservers:vars] |
| 4, 5 | group_vars/all, beside the inventory, then beside the playbook |
| 6, 7 | group_vars/<group>, beside the inventory, then beside the playbook |
| 8 | Host vars written on the host’s line in the inventory file |
| 9, 10 | host_vars/<host>, beside the inventory, then beside the playbook |
Two results catch people. A key set under [webservers:vars] loses to the same key in any group_vars/all file, even though webservers is more specific. Role defaults sit at 2, so inventory overrides them per group (the defaults and vars split is covered in roles vs collections). Play vars: at 12 and everything above it beat inventory.
Using inventory variables in a playbook
An inventory variable needs no import: Ansible flattens variables onto each host before the play runs, so a task names it directly. Quote the whole value when it starts with {{, or the YAML will not parse:
- hosts: webservers
tasks:
- name: Show a value inherited from the production group
ansible.builtin.debug:
msg: "{{ ntp_server }}"
Three magic variables read inventory across hosts, as the facts and magic variables guide shows:
hostvars['db01.example.com']['ntp_server']reads another host’s variable from any task.groups['dbservers']lists a group’s hosts, so{% for host in groups['dbservers'] %}loops over them in a template.group_nameslists the current host’s groups, sowhen: "'production' in group_names"tests membership instead of a hostname.
Patterns: scoping a run
A pattern is how a play or an ad hoc command names its targets. It is the content of the hosts: line, the unflagged argument to ansible, and the argument to --limit. The documented operators are small enough to memorise:
allor*targets everything.host1:host2orhost1,host2targets a list. Comma is preferred when ranges or IPv6 addresses are involved, since colons are ambiguous there.webservers:dbserversis a union: every host in either group.webservers:!atlantaexcludes, targeting hosts inwebserversthat are not inatlanta.webservers:&stagingintersects, targeting only hosts in both.
These combine. The documentation’s own example, webservers:dbservers:&staging:!phoenix, targets machines in webservers or dbservers that are also in staging, excluding anything in phoenix. Wildcards work against FQDNs and IP addresses when hosts are named that way in inventory, so 192.0.* and *.example.com are valid, and wildcards can be mixed with group names.
Limiting a run with —limit
--limit (or -l) narrows the hosts a play already targets using the same syntax, as in --limit 'webservers:!web02.example.com'. The patterns guide requires single quotes around any pattern containing ! to prevent bash interpolation, and --limit @hosts.txt reads targets from a file. Patterns also take positions, where webservers[0:2] means the first three hosts because slices include their end, and regular expressions prefixed with ~, such as ~(web|db).*\.example\.com.
Operators are processed : and , first, then &, then !, whatever order they are written in. A pattern that matches nothing only warns Could not match supplied host pattern, ignoring, so a misspelled group shrinks the run instead of failing it.
Design groups so patterns do the combining
Intersection is the reason to model an estate along several independent axes rather than one. Give each host a function group, an environment group and a location group, and any combination is addressable without inventing a new group:
--limit 'webservers:&production:&eu_west'
The alternative, a group per combination such as prod_web_eu, doubles in size every time an axis is added and drifts out of date the moment a machine moves. Independent axes also make variable placement obvious: tuning belongs to the function group, credentials and endpoints to the environment group, and mirrors or time zones to the location group.
Keep the axes at the same depth, ideally directly under all. If webservers sits under production but eu_west hangs off all, webservers is deeper and wins every conflict, whatever ansible_group_priority says.
Verify inventory with the ansible-inventory command
Inventory bugs are quiet. A misplaced host does not throw an error; it just receives the wrong configuration. ansible-inventory prints the inventory as Ansible sees it after every source and variable file is merged (add -i for a non-default source):
| Command | Shows |
|---|---|
ansible-inventory --graph | The group tree with host membership |
ansible-inventory --graph --vars webservers | One group’s subtree, with variables on each entry |
ansible-inventory --host web01.example.com | Every variable one host ends up with after merging |
ansible-inventory --list | The full inventory as JSON; add -y for YAML or --toml for TOML |
ansible-inventory --list --limit 'webservers:&production' | Only the hosts matching a pattern |
ansible-inventory --list --export --output inv.json | An export-optimised dump written to a file |
--graph and --host ignore --limit, so filter with --list. Pass --playbook-dir when group_vars/ sits beside the playbook rather than the inventory. To check what a play would hit, ansible-playbook --list-hosts resolves the pattern without running it, and the ad hoc ansible command takes the same flag.
Running the graph command after any inventory change, especially one that renames or reparents a group, catches the alphabetical merge problem before a playbook does.
Dynamic inventory
Inventory plugins generate hosts from a source of truth rather than a file: cloud provider APIs, a container platform, or a CMDB. They are configured with a small YAML file that the -i flag points at, and they can be mixed with static files in the same inventory directory. When Terraform creates the hosts, see which tool owns which layer.
The constructed plugin is the piece that makes dynamic inventory usable, because raw cloud output rarely has the groups you want. It builds groups from existing facts and tags, so instances carrying a role tag become function groups automatically. That keeps the estate self-classifying: a new machine tagged correctly at build time lands in the right groups without an inventory edit.
Common mistakes
Writing conditionals in tasks that test hostnames instead of creating a group. Depending on the alphabetical merge of two same-level groups without setting ansible_group_priority. Setting ansible_group_priority in group_vars/, where it has no effect, or expecting it to beat a deeper child group. Expecting an INI :vars value to arrive as a boolean. Filling host_vars with settings that describe a role. Putting connection details such as ansible_user in plays, making them non-portable. Building combinatorial groups like prod_web_eu instead of intersecting independent axes. Changing group names without re-running the inventory graph.
Once scope is right, the next two questions are how many hosts run at once and in what order. Concurrency and idempotence are covered in how Ansible runs a playbook, execution ordering in linear vs free vs host_pinned, and the inventory sizing calculator models what a given host count implies for forks and runtime.
FAQ
can a host be in multiple groups in ansible
Yes. A host can be listed in any number of groups, and membership in a child group also makes it a member of every parent. Ansible still processes each host once and merges variables from all of its groups, sorted by depth, then ansible_group_priority, then group name, with the last merged value winning.
how do i get a variable from another host in ansible
Use hostvars, the magic dictionary of every inventory host and its variables: {{ hostvars['db01.example.com']['ntp_server'] }} returns that host’s value from any task. Pair it with groups to walk a whole group, as in {% for host in groups['dbservers'] %}, reading hostvars[host] for each member inside the loop.
how do i list all hosts in an ansible group
Run ansible-inventory -i inventory/ --graph webservers to print the group’s subtree, including hosts that arrive through child groups. For a flat list after pattern matching, ansible webservers --list-hosts prints the matching hosts without executing anything. Inside a playbook, the same membership is available as groups['webservers'].
why is my ansible inventory variable not being used
Usually a higher-precedence source sets the same name. Play vars:, set_fact, role vars/ and -e all beat inventory, and a group_vars/all file beats inline [group:vars] values. Run ansible-inventory --host <name> to see what inventory actually resolved, and remember that INI :vars values are always strings.