The merged inventory: what wins, and how to check it
In one sentence
When there is more than one inventory source, hosts and groups are merged, and for a variable with the same name, the one read later wins. What decides that "later" is the order in which you wrote -i, the order of file names inside a directory, and the depth and alphabetical order of groups.
Why this was needed
When the inventory is a single file, you know it by reading it. But organizations that end up with just one file are rare. The platform team manages the common hosts, and each team manages the hosts of its own services. Production and staging are split into separate files so they do not get mixed up by mistake. Cloud resources are produced by a dynamic inventory plugin instead of being written by people. When this happens, -i is attached two or more times, and at some point nobody can answer "what is web1's app_port right now" by reading files.
There are two kinds of incidents here. The first is the wrong servers being included. If two files have the same group name, their hosts are merged, so a host you believed to be only in the staging file enters the production deployment targets. The second is the wrong value winning. A group variable left in an old file overrides a value in a new file, or conversely a newly made group_vars/ file quietly pushes out a value inside the inventory.
Neither incident can be prevented by the approach of "infer it by reading the files". There is only one way to prevent them — ask the machine for the merged result before running.
How it works
When you give -i several times
You can give it several times, as in ansible-playbook -i a.ini -i b.ini site.yml. The sources are read in the order written, and the result is merged into one inventory.
| What | When merged |
|---|---|
| Hosts | Union. The same name is one host |
| Groups | Union. With the same name, the members are merged |
| Variables with the same name | The source read later wins |
If a host with the same name is in two files, it does not become two machines but merges into one. The same is true even if ansible_host is written differently — the later one wins, and the earlier value disappears without any warning. If you don't know this behavior when you add a new file that keeps the host name and changes only the address while moving an inventory, you wander for days.
When you give a directory
If you give a directory, as in -i inventory/, it reads the files inside in name order. That is why the convention is to attach numeric prefixes like 10-base, 20-prod, and 30-overrides. The name decides the order, and the order decides the winner.
There is a trap here that catches every beginner once. A directory inventory skips certain extensions by default. The configuration item is named inventory_ignore_extensions, and .ini is in its default list. So are .cfg, .retry, .md, .txt, and editor backups (.bak, and files ending with a tilde).
So inventory/hosts.ini is read with -i inventory/hosts.ini but not read with -i inventory/. No error appears. It just behaves as if the hosts written in that file did not exist. For an INI-format inventory placed inside a directory, strip the extension (10-base) or use a different name. The .yml and .yaml of YAML inventories are not in the list, so they are read as they are.
group_vars and host_vars on disk
There are two places to write inventory variables. One is inside the inventory file ([web:vars], or vars: in YAML), and the other is the group_vars/ and host_vars/ directories next to the inventory. If the two define the same variable, who wins?
inventory/
10-base [web:vars] app_port=8080
group_vars/
all.yml app_port: 9000 owner: platform
web.yml app_port: 9090
host_vars/
web1.yml app_port: 9999
The result is this. web1 gets 9999, web2 gets 9090, and hosts of other groups get 9000. The 8080 written inside the inventory file appears nowhere. This is because, in the precedence list of the official documentation, "inventory file or script group vars" is below "inventory group_vars/*". Even in the same directory, a group variable inside the file is the weakest — this is the spot that most often surprises people in practice.
If you memorize the order in one line, it is this. Group variables inside the inventory file → group_vars/all → group_vars/group → host variables inside the inventory file → host_vars/host. The further back you go, the narrower it is, and the narrower one wins.
A group_vars/ directory can exist in two places, next to the inventory and next to the playbook, and with the same name, the one next to the playbook wins. If you split them across two places, nobody can find the values half a year later, so it is better to settle on one place and write it down as a team rule.
When groups of the same depth overlap
What happens when a host belongs to several groups and those groups define the same variable? The default rule is merge in alphabetical order, and the later one wins. A host that belongs to both alpha and zulu gets the value of zulu. Alphabetical order cannot carry any meaning, so a design that relies on this default is itself a warning sign.
The handle that flips the default is ansible_group_priority. The default is 1, and the larger the number, the later it is merged and the more it wins.
[alpha:vars]
color=from-alpha
ansible_group_priority=10
[zulu:vars]
color=from-zulu
With this, beating zulu, which comes later alphabetically, alpha wins. There is one thing to watch out for — this variable takes effect only if it is written inside an inventory source. If you write it in group_vars/alpha.yml, it is not honored. It is a value used to decide the order in which those files are read in, so writing it inside those files is already too late.
And this priority has meaning only between groups of the same depth. Between a parent and a child, depth wins.
The child beats the parent
Under [prod:children], if you put web and db, web is a child of prod. If the two groups define the same variable, the child web wins. It has nothing to do with alphabetical order — even if the parent's name is zprod and it comes later alphabetically, the child wins.
This rule is natural. The parent is broad and the child is narrow, and the narrow one always wins. That is why a design that puts common defaults in prod and overrides only what is needed in web holds up. all is an ancestor of every group, so it is the broadest and the weakest.
How to ask for the merged result
When you suspect the inventory, staring at files is a waste of time. The answers are in three commands.
| Command | What it answers |
|---|---|
ansible-inventory -i ... --graph |
The group structure and members. Which host is in which group |
ansible-inventory -i ... --list |
The whole merged result as JSON. Good for checking with a script |
ansible-inventory -i ... --host web1 |
All the variables that host ends up with |
--host is especially used to measure values. It answers "what is web1's app_port in the end" in 3 seconds. If you give --graph --vars, the graph shows the variables too. If you put one step in CI that checks the output of these commands, you can catch the incident "the wrong servers were included" before deployment.
Host patterns — the syntax for narrowing targets
As the inventory grows, you need targets such as "web servers in production that do not belong to staging". Pattern syntax does that job.
| Symbol | Meaning | Example |
|---|---|---|
: |
Union | web:db |
:& |
Intersection | web:&prod |
:! |
Difference | prod:!staging |
* |
Glob | web*.example.com |
~ |
Regular expression (put in front) | Choose names by regular expression |
Chain three together and you get web:&prod:!staging. It is a host that is web, is prod, and is not staging. Always check with --list-hosts before running. In particular, for a difference, it is not an error even if the result after subtraction is empty, which is where a deployment that ends in a green light with no target at all comes from. --limit also uses the same syntax and narrows once more the pattern written in the playbook's hosts:.
Where does dynamic inventory sit in this picture
In the cloud, people do not write the host list. Inventory plugins such as aws_ec2 and gcp_compute query an API and produce hosts and groups. The merging rules are the same — a dynamic source is also just one source written in -i, and depending on the order it mixes with static files. This is why a common design is "the host list from the dynamic source, the values the team decides from static group_vars/". If the dynamic source only produces the group names, the group_vars/ file matching that name attaches the values. (This lab environment has neither the internet nor credentials, so you cannot run a dynamic plugin. Just remember that the rules are the same.)
What you see in the field
Case 1 — the day a directory swallowed a whole file. While moving an inventory to a directory, we put the existing hosts.ini into inventory/ as it was. When we ran ansible-inventory --graph, only half of the groups appeared. There was no error at all. The cause was that the .ini extension is in the default ignore list, and as soon as we removed the extension from the file name, it was solved on the spot. Since that day, a rule has existed to attach the difference of the --graph output to every PR that changes the inventory.
Case 2 — the value did not change after creating group_vars. There was an incident in the opposite direction too. To change a port in a hurry, we edited [web:vars] inside the inventory file, but the value did not change. Someone had created group_vars/web.yml a few months earlier, and that side was stronger. That was when we learned that a group variable inside the file is the weakest place. Now we write no variables in the inventory file at all and use only the single place group_vars/. Reducing the places is better than memorizing the precedence.
Case 3 — a configuration that relied on alphabetical order. The same host was in two groups, app and backend, and both defined java_opts. backend was winning by alphabetical order, and one day there was a cleanup task that renamed the group from app to zapp. Only the name was changed, but the winner flipped and the heap settings were completely different. In such places, you must state the intent explicitly with ansible_group_priority, or fix the design in the first place so that a host does not belong to two groups that define the same variable.
What you will do in the next lab
You start by making two inventory files and giving -i twice. You put the same things in a directory and confirm that the file-name order decides the winner, and put one file with the .ini extension alongside to see for yourself that it is skipped quietly. Then you create group_vars/all, group_vars/그룹 (the group's name), and host_vars/호스트 (the host's name), and measure which different value wins per host, and confirm that a variable inside the inventory file is weaker than those three. For two groups of the same depth, you flip the alphabetical order with ansible_group_priority, and for a parent and a child, you see that depth wins. Finally, you narrow the targets with patterns and leave the result in a file, and build a tool that answers "which value wins for this variable in the end".