Ansible Lesson 3 of 42

Ansible Inventory, In Depth: Static INI & YAML, Groups, Host/Group Vars & Patterns

Every Ansible command answers two questions: what do I do and where do I do it. Modules and playbooks answer the first; the inventory answers the second. The inventory is the list of machines Ansible can manage, the groups that let you address them in bulk, and the per-host and per-group variables that let one playbook behave correctly across dev, staging, and production without a single if environment == "prod" in the tasks. Get the inventory right and the rest of Ansible falls into place; get it wrong and you will spend your days wondering why a play “ran” but changed nothing, or — far worse — changed something on the wrong box.

This lesson is the exhaustive reference for static inventory: a file (or directory) you write by hand. We will lay the INI and YAML formats side by side, cover host ranges and aliases, the connection variables that tell Ansible how to reach a host, groups and nested child groups, the two groups Ansible always creates for you (all and ungrouped), the host_vars/ and group_vars/ directories and exactly how their precedence resolves, the host-pattern mini-language you use with --limit and as a play’s hosts:, how to point Ansible at several inventories at once and how they merge, and the two commands — ansible-inventory --list and --graph — that let you see what Ansible actually parsed. Everything targets current Ansible (ansible-core 2.17+ / Ansible 10+, 2026) and uses FQCN (namespace.collection.module) throughout. Dynamic inventory — querying the cloud for hosts at runtime — gets a teaser at the end and a full lesson of its own.

Learning objectives

By the end of this lesson you will be able to:

Prerequisites & where this fits

You should have a working control node with ansible installed and at least one machine you can reach over SSH — the previous lesson, Installing & configuring Ansible, covers that setup, SSH keys, and the ansible.cfg inventory = setting that tells Ansible where your default inventory lives. You should also understand the agentless push architecture and idempotency from the opening lesson, because the inventory is the push targets list. This lesson sits in the Inventory module of the Ansible Zero-to-Hero course — embedded module course-ansible-inventory — and is the foundation for everything that follows: ad-hoc commands, playbooks, and roles all select their targets with the patterns and groups defined here. Its natural sequel for real fleets is the dynamic inventory lesson, where the host list becomes a live query against AWS and Azure rather than a file you maintain.

Core concepts

A few mental models make the whole inventory make sense.

The inventory is a graph, not a list. It looks like a flat list of hostnames, but internally Ansible builds a tree: every host belongs to one or more groups, groups can contain child groups, and at the very top sits a group called all that contains every host. When you ask Ansible to run against a pattern, it walks this graph to compute the set of target hosts.

Inventory and variables are inseparable. The inventory is not just which hosts exist — it is also what is true about them. A host can carry variables (its IP, its SSH user, the version of an app it should run); a group can carry variables that apply to every member. This is how one role configures Nginx differently in webservers than in proxies without any conditional logic in the role itself.

Two groups always exist, for free. all contains every host in the inventory. ungrouped contains every host that is not a member of any group other than all. You never declare these; Ansible creates them. Both can carry variables (in group_vars/all and group_vars/ungrouped), and group_vars/all is the canonical place for fleet-wide defaults.

Connection variables are just variables with reserved names. Telling Ansible to reach a host on port 2222 as user deploy is not special syntax — it is setting the variables ansible_port=2222 and ansible_user=deploy. They live wherever any variable can live: inline on the host line, in host_vars/, in group_vars/. This uniformity is why the precedence rules below matter so much.

Static vs dynamic. A static inventory is a file you maintain by hand (this lesson). A dynamic inventory is generated at runtime by an inventory plugin that queries a source of truth — AWS, Azure, a CMDB. Both produce the same in-memory graph; the difference is only where the data comes from. We focus on static here and signpost dynamic at the end.

INI vs YAML: the two static formats, side by side

Ansible accepts a static inventory in two formats: the terse, line-oriented INI style and the structured YAML style. Both express the same model — hosts, groups, children, variables — and Ansible chooses the parser by content, not by file extension. (.ini, .yml, .yaml, or no extension all work; what matters is that the file parses.) For a directory-based inventory you can even mix files of both formats in the same directory.

Here is the same small inventory written both ways. INI:

# inventory.ini
[webservers]
web1.example.com
web2.example.com

[dbservers]
db1.example.com ansible_user=postgres

[production:children]
webservers
dbservers

[production:vars]
env=prod

YAML (the equivalent):

# inventory.yml
all:
  children:
    webservers:
      hosts:
        web1.example.com:
        web2.example.com:
    dbservers:
      hosts:
        db1.example.com:
          ansible_user: postgres
    production:
      children:
        webservers:
        dbservers:
      vars:
        env: prod

Note the YAML quirks that trip up newcomers: each host is a mapping key with a trailing colon and an empty value (web1.example.com:), not a list item; host variables are a nested mapping under that key; and group membership is expressed by nesting under children: rather than a separate [group:children] stanza. The implicit all at the top is optional in YAML but conventional.

Format comparison

Aspect INI YAML
Syntax style Line-oriented, stanza headers in [brackets] Indented nested mappings
Readability for small inventories Excellent — very terse More verbose
Readability for deep nesting / many vars Degrades; :vars stanzas get unwieldy Excellent — structure is explicit
Host with many variables One long line, or move to host_vars/ Clean nested mapping
Variable data types Everything is a string unless you opt in (see below) Native YAML types: ints, bools, lists, dicts
Lists / dicts as inline host vars Not possible inline (use host_vars/) Native and natural
Comments # or ; # only
Group of groups (children) [group:children] stanza children: key
Group variables [group:vars] stanza vars: key under the group
Parser INI-like (Ansible’s own, not Python configparser) Standard YAML
Best for Quick labs, flat fleets, hand-edits Real projects, rich vars, anything version-controlled long-term

A practical rule: INI for a throwaway lab or a genuinely flat list; YAML for anything you will live with. YAML’s ability to hold native lists and dicts inline, and its explicit structure, pays off the moment your inventory grows. That said, the strong recommendation regardless of format is to keep the inventory file thin — hosts and group membership only — and push variables out to host_vars/ and group_vars/ directories, which we cover below.

The INI string-vs-type gotcha

In INI, every inline variable is a string by default. http_port=8080 gives you the string "8080", not the integer 8080, and enabled=false gives you the string "false" — which is truthy in a when: test, the classic beginner bug. There are two escapes:

This is the single biggest reason teams that start in INI migrate vars to YAML group_vars/.

Hosts: ranges, aliases & connection variables

A “host” in the inventory is an entry Ansible can target. Its name is usually a resolvable DNS name or an IP, but it can also be an alias decoupled from the real address.

Host ranges

Defining web01 through web10 by hand is tedious and error-prone. Inventory supports numeric and alphabetic ranges with [start:end], and you can use several ranges in one pattern (a Cartesian product).

Pattern Expands to
web[01:10].example.com web01, web02, … web10 (zero-padded — the padding in 01 is preserved)
web[1:10].example.com web1, web2, … web10 (no padding)
db-[a:f] db-a, db-b, … db-f (alphabetic range)
web[01:10:2].example.com web01, web03, web05, web07, web09 (step of 2)
node[1:3]-rack[1:2] node1-rack1, node1-rack2, node2-rack1, … (two ranges → product)

In INI you write the range directly as the host line; in YAML the range goes as the mapping key:

[webservers]
web[01:10].example.com
webservers:
  hosts:
    web[01:10].example.com:

The leading-zero rule matters: [01:10] preserves the padding (web01), while [1:10] does not (web1). Match whatever your hostnames actually are.

Aliases and the ansible_host decoupling

An alias is an inventory name that is not the connection address. You give the host a friendly label and then tell Ansible the real address with ansible_host:

[webservers]
web1 ansible_host=192.0.2.11
web2 ansible_host=192.0.2.12 ansible_port=2222
jump ansible_host=bastion.example.com ansible_user=ec2-user

Now web1 is just a name you use in patterns and group_vars; Ansible connects to 192.0.2.11. Aliases are invaluable when DNS is unreliable, when you address hosts by role rather than name, or when several inventory entries point at the same physical box on different ports (a common pattern for testing in containers).

Connection variables — the full set

These reserved variables control how Ansible reaches and operates on a host. They can be set inline, in host_vars/, in group_vars/, or globally in ansible.cfg/extra-vars. The most important ones:

Variable What it controls Typical values / default
ansible_host The real network address to connect to (decouples from the inventory name) DNS name or IP; defaults to the inventory hostname
ansible_port SSH/WinRM port 22 (SSH) by default
ansible_user Remote login user defaults to remote_user in ansible.cfg, else the control-node user
ansible_connection Connection plugin ssh (default), local, winrm, psrp, docker, community.docker.docker
ansible_ssh_private_key_file Path to the private key for this host/group unset → SSH agent / default key
ansible_ssh_common_args Extra args appended to every ssh/scp/sftp call (e.g. a ProxyJump) unset
ansible_ssh_extra_args Extra args for the ssh invocation only unset
ansible_password SSH password (prefer keys; if set, keep it in Vault) unset
ansible_become Whether to privilege-escalate by default false
ansible_become_method Escalation method sudo (default), su, doas, pbrun, pfexec, runas
ansible_become_user Target user after escalation root
ansible_become_password Escalation password (keep in Vault) unset
ansible_python_interpreter Which Python the module code runs under on the target auto (auto-discovery); set explicitly to silence warnings, e.g. /usr/bin/python3
ansible_shell_type Shell family on the target sh (default), csh, fish, powershell
ansible_winrm_transport WinRM auth transport (Windows) ntlm/kerberos/credssp/basic

A frequent gotcha: setting ansible_user on a group is the clean way to say “all jump-box hosts log in as ec2-user,” but a per-host ansible_user overrides it (host vars beat group vars — see precedence below). For a bastion/jump-host hop, the idiomatic setting is ansible_ssh_common_args: '-o ProxyJump=bastion' placed on the group of internal hosts.

Groups & nested child groups

A group is a named bag of hosts. You target a group by name, and you attach variables to a group so they apply to every member. A host can belong to many groups simultaneously — web1 can be in webservers, production, and frankfurt at once, inheriting variables from all three.

Declaring groups

INI uses a [groupname] stanza; YAML nests hosts under groupname → hosts:.

[webservers]
web1
web2

[dbservers]
db1
all:
  children:
    webservers:
      hosts:
        web1:
        web2:
    dbservers:
      hosts:
        db1:

Child groups (groups of groups)

A child group is a group nested inside another group; the parent then contains every host of every child, transitively. This is how you build hierarchies like production = (webservers + dbservers), or emea = (frankfurt + dublin). In INI you use a [parent:children] stanza listing the child group names (not hosts); in YAML you nest under children:.

[webservers]
web1
web2

[dbservers]
db1

[production:children]
webservers
dbservers

[emea:children]
production
all:
  children:
    production:
      children:
        webservers:
          hosts:
            web1:
            web2:
        dbservers:
          hosts:
            db1:
    emea:
      children:
        production:

Now production resolves to {web1, web2, db1}, and emea resolves to the same set (because production is its only child). Child-group nesting can go as deep as you like, but cycles are not allowed (a group cannot be its own ancestor). A host that appears in a child is automatically a member of every ancestor, so you can target the broad emea or the narrow dbservers from the very same inventory.

Group reference table

Concept INI syntax YAML syntax Notes
Define a group [web] then host lines web:hosts: → host keys A host may be in many groups
Group of groups (children) [parent:children] then group names parent:children: → group keys Parent gets all descendant hosts
Group variables [web:vars] then key=value web:vars:key: value Apply to every member; lowest of the var sources here
Implicit top group n/a (always present) all: (conventional to write) Contains every host
Implicit catch-all n/a (always present) ungrouped: Hosts in no group but all
Empty group (placeholder) [web] with no hosts web: with hosts: {} Valid; useful as a --limit target later

The implicit all and ungrouped groups

Two groups exist in every inventory whether or not you mention them:

A host that is a member of any real group is not in ungrouped. The two groups are not mutually exclusive with each other in the sense that every host is always in all; ungrouped is the subset of all with no other membership.

host_vars/ and group_vars/ directories

You can cram variables onto the host line and into [group:vars] stanzas, but at any real scale you should not. The maintainable pattern is to keep the inventory file thin and put variables in two specially named directories that Ansible loads automatically: group_vars/ and host_vars/.

How they are discovered

Ansible looks for group_vars/ and host_vars/ directories in two locations, and loads from both:

  1. Adjacent to the inventory file/directory — e.g. if your inventory is inventories/prod/hosts, then inventories/prod/group_vars/ and inventories/prod/host_vars/.
  2. Adjacent to the playbookgroup_vars/ and host_vars/ next to the playbook you run.

Inside each directory, the filename matches the group or host name:

inventories/prod/
├── hosts                    # the inventory file
├── group_vars/
│   ├── all.yml              # applies to every host
│   ├── all/                 # OR a directory; every file inside is merged
│   │   ├── 10-network.yml
│   │   └── 20-ntp.yml
│   ├── webservers.yml       # applies to the webservers group
│   └── production.yml
└── host_vars/
    ├── web1.yml             # applies only to web1
    └── db1/                 # a directory works here too
        └── secrets.yml      # often a Vault-encrypted file

Two important details:

group_vars vs host_vars precedence

When the same variable is defined in more than one of these places, the more specific wins. Within the inventory/vars sources covered by this lesson, the order from lowest to highest is:

Rank (low → high) Source Example
1 group_vars/all fleet-wide default
2 parent group vars group_vars/production
3 child group vars group_vars/webservers (child of production)
4 host_vars host_vars/web1
5 (later: play vars, task vars, extra-vars -e = highest of all)

Three rules capture almost everything:

  1. Host beats group. A variable in host_vars/web1 overrides the same variable in any group web1 belongs to. The host is the most specific scope.
  2. Child group beats parent group. group_vars/webservers overrides group_vars/production when webservers is a child of production. More specific group wins.
  3. Same-level ties break by group depth, then name. If a host is in two unrelated groups at the same depth that both set x, Ansible resolves the tie by group priority if set (the ansible_group_priority variable), otherwise by the group’s position in the merge — practically, avoid this by not defining the same var in two sibling groups. When you must, set ansible_group_priority (default 1; higher wins) on the group that should take precedence.

group_vars/all is special only in that it is the lowest group precedence — the perfect home for defaults you intend to override. Note that the full Ansible variable precedence has ~22 levels (extra-vars at the top, role defaults at the bottom); the table above is the slice that involves inventory. The complete ordering is covered in the variables & precedence lesson.

A worked precedence example

group_vars/all.yml          -> ntp_server: pool.ntp.org
group_vars/production.yml    -> ntp_server: ntp.prod.internal
group_vars/webservers.yml    -> log_level: info
host_vars/web1.yml          -> log_level: debug

With web1 a member of webservers (child of production):

Inventory patterns: targeting hosts precisely

A pattern is how you tell Ansible which hosts to act on. The same mini-language is used in three places: a playbook’s hosts: line, an ad-hoc command (ansible <pattern> -m ...), and the --limit flag (which intersects with whatever the play already selected). Mastering patterns is the difference between confidently running against exactly the right boxes and nervously hoping you did.

The pattern operators

Pattern Meaning Example Selects
all or * Every host in the inventory all the whole fleet
groupname All hosts in a group webservers members of webservers
hostname A single host (by inventory name) web1 just web1
host:host / g:g Union (logical OR) webservers:dbservers members of either group
g:&g Intersection (logical AND) webservers:&production webservers that are also in production
g:!g Exclusion (logical NOT) webservers:!web1 webservers except web1
*.example.com Glob (wildcard) *.example.com hosts/groups whose name matches the glob
web* Glob prefix web* web1, web2, webservers, … (matches host and group names)
~regex Regular expression (leading ~) ~web\d+\.example\.com hosts matching the regex
g[0] Index into a group webservers[0] the first host of webservers
g[0:2] Slice of a group webservers[0:2] the first three hosts (inclusive range)
g[-1] Last host of a group webservers[-1] the last host

Patterns compose left to right. A real one might read:

ansible 'webservers:&production:!web5' -m ansible.builtin.ping

…which means “hosts that are in both webservers and production, minus web5.” Order matters and the operators are applied in sequence, so build the union/intersection first and subtract last for predictable results.

Quoting and shell safety

:, &, !, *, ~, [, and ] are all meaningful to your shell. Always single-quote a non-trivial pattern so the shell passes it to Ansible verbatim:

ansible 'webservers:!web1' -m ansible.builtin.ping     # correct
ansible webservers:!web1 -m ansible.builtin.ping       # the shell may mangle ! and :

! in particular triggers history expansion in interactive bash; single quotes (not double) are the safe choice.

--limit: intersect, don’t replace

--limit (or -l) further restricts the hosts a play would otherwise run on — it intersects with the play’s hosts:. A play targeting hosts: production run with --limit web1 touches only web1 and only if web1 is in production. --limit accepts the full pattern language, including unions and exclusions:

ansible-playbook site.yml --limit 'webservers:!web5'
ansible-playbook site.yml --limit @retry_hosts.txt    # @file = one host per line

The @filename form reads a newline-separated host list from a file — exactly the format Ansible writes to playbook.retry after a partial failure, so you can re-run only the hosts that failed.

Patterns find groups too

A subtle but useful fact: a bare name or glob matches both host names and group names. web* matches the host web1 and the group webservers. If you have a host and a group with overlapping names, be deliberate — this is a reason to give groups and hosts visibly different naming conventions.

Multiple inventories & merging

You are not limited to one inventory source. Pass -i multiple times, or point -i at a directory (Ansible reads every file in it), or set a list in ansible.cfg. This is how teams split inventory by environment, by region, or by static-vs-dynamic source.

# Two explicit sources on the command line
ansible-playbook site.yml -i inventories/prod/hosts -i inventories/shared/hosts

# A whole directory (every file parsed and merged)
ansible-playbook site.yml -i inventories/prod/

# In ansible.cfg
# [defaults]
# inventory = inventories/prod/hosts,inventories/shared/hosts

How merging behaves:

A common production layout:

inventories/
├── prod/
│   ├── hosts
│   ├── group_vars/
│   └── host_vars/
├── staging/
│   ├── hosts
│   ├── group_vars/
│   └── host_vars/
└── shared/
    └── group_vars/all.yml   # truly global defaults (or keep these per-env)

Each environment is fully self-contained, so -i inventories/prod/ and -i inventories/staging/ can never accidentally share host-specific data — a deliberate guard-rail against running a staging change against production.

Inspecting the inventory: ansible-inventory

You should never guess what Ansible parsed. The ansible-inventory command renders the fully-resolved inventory — every host, group, child relationship, and variable — so you can verify it before you run anything.

Command What it shows
ansible-inventory -i hosts --list The entire inventory as JSON: groups, their hosts, children, and all variables (under _meta.hostvars)
ansible-inventory -i hosts --graph An ASCII tree of groups → child groups → hosts — the fastest way to see the hierarchy
ansible-inventory -i hosts --graph --vars The graph annotated with each host’s and group’s variables
ansible-inventory -i hosts --host web1 Just the resolved variables for a single host
ansible-inventory -i hosts --list --yaml The full dump in YAML instead of JSON (easier to read)
ansible-inventory -i hosts --graph webservers Limit the graph to one group’s subtree
ansible-inventory -i hosts --list --export Resolve as it would for export (affects how some plugin vars render)

A typical --graph looks like:

@all:
  |--@ungrouped:
  |--@production:
  |  |--@dbservers:
  |  |  |--db1
  |  |--@webservers:
  |  |  |--web1
  |  |  |--web2

The leading @ marks a group; bare names are hosts; indentation shows the child relationships. If a host you expected is missing, or appears under ungrouped when you meant it to be in a group, --graph shows you instantly — far faster than discovering it when a play runs against the wrong set.

Static vs dynamic inventory (teaser)

Everything above describes static inventory: files you maintain. The moment your fleet autoscales, a static file becomes a liability — the host you carefully tagged web03 is terminated and replaced with a new private IP, and your next run targets a machine that no longer exists. The answer is dynamic inventory: an inventory plugin (e.g. amazon.aws.aws_ec2, azure.azcollection.azure_rm) that, at the start of every run, queries the cloud control plane and builds the host graph live, shaping cloud tags into groups with keyed_groups and compose. The patterns, groups, group_vars/, and precedence rules you learned here apply identically to a dynamic inventory — only the source of the hosts changes. The full treatment, including caching and secrets, is in the dynamic inventory lesson.

Ansible inventory anatomy: a static inventory file (INI or YAML) feeding the in-memory inventory graph — the implicit all group at the top containing every host plus ungrouped for uncategorised hosts, real groups like webservers and dbservers rolling up into child-group parents such as production and emea, with host_vars/ and group_vars/ directories layering variables onto hosts and groups by precedence, and pattern operators (union :, intersection :&, exclusion :!) selecting target subsets that --limit further narrows

The diagram shows how a flat-looking inventory file becomes a graph: all at the top, real groups and their child-group parents in the middle, host_vars//group_vars/ layering variables by precedence, and pattern operators carving out the exact set of hosts a command will touch.

Hands-on lab

This lab is free — it runs entirely on your control node plus two local Docker containers as “managed nodes,” so there is nothing to provision in the cloud and ₹0 cost. You will build a static inventory in both formats, add child groups and vars directories, exercise patterns, and inspect the result. Adjust to plain localhost targets if you prefer not to use containers.

Step 0 — a working directory

mkdir -p ~/ansible-inventory-lab && cd ~/ansible-inventory-lab

Step 1 — two throwaway “managed nodes” (optional but realistic)

# Start two lightweight containers we can SSH-less-connect to via the docker connection
docker run -d --name node1 --hostname node1 alpine:3 sleep infinity
docker run -d --name node2 --hostname node2 alpine:3 sleep infinity

We will reach these with ansible_connection=community.docker.docker, which needs no SSH. (If you do not have Docker, skip this and use localhost with ansible_connection=local in Step 2.)

Step 2 — write the YAML inventory

Create inventory.yml:

all:
  vars:
    ansible_connection: community.docker.docker   # talk to the containers via Docker
  children:
    webservers:
      hosts:
        node1:
    dbservers:
      hosts:
        node2:
    production:
      children:
        webservers:
        dbservers:
      vars:
        env: prod

Step 3 — add vars directories

mkdir -p group_vars host_vars
printf 'ntp_server: pool.ntp.org\n' > group_vars/all.yml
printf 'log_level: info\n'          > group_vars/webservers.yml
printf 'log_level: debug\n'         > host_vars/node1.yml

Step 4 — inspect what Ansible parsed

ansible-inventory -i inventory.yml --graph
ansible-inventory -i inventory.yml --host node1

Expected — the graph shows production containing the webservers and dbservers child groups, and --host node1 shows log_level: debug (host_vars beat the webservers group) alongside env: prod and ntp_server: pool.ntp.org:

@all:
  |--@production:
  |  |--@dbservers:
  |  |  |--node2
  |  |--@webservers:
  |  |  |--node1
  |--@ungrouped:
{
    "ansible_connection": "community.docker.docker",
    "env": "prod",
    "log_level": "debug",
    "ntp_server": "pool.ntp.org"
}

Step 5 — exercise patterns

# Whole fleet
ansible all -i inventory.yml -m ansible.builtin.ping

# Just the webservers group
ansible webservers -i inventory.yml -m ansible.builtin.ping

# Union: web + db (everything in production, the long way)
ansible 'webservers:dbservers' -i inventory.yml -m ansible.builtin.ping

# Intersection: webservers that are ALSO in production
ansible 'webservers:&production' -i inventory.yml -m ansible.builtin.ping

# Exclusion: production minus node1
ansible 'production:!node1' -i inventory.yml -m ansible.builtin.ping

Step 6 — prove the limit intersects

# A "play" pattern of production, narrowed to node2 only
ansible production -i inventory.yml --limit node2 -m ansible.builtin.ping
# Narrowing to a host NOT in production selects nothing:
ansible production -i inventory.yml --limit node1 -m ansible.builtin.debug -a "msg=hi"

The second command runs against node1 because node1 is in production (via webservers); change --limit to a non-member and the recap shows zero hosts — proof that --limit intersects rather than replaces.

Validation

Cleanup

docker rm -f node1 node2          # remove the containers
cd ~ && rm -rf ~/ansible-inventory-lab

Cost note

₹0. Everything ran locally in containers (or on localhost). No cloud resources were created, so there is nothing billable to delete beyond the local files and containers removed above.

Common mistakes & troubleshooting

Symptom Likely cause Fix
[WARNING]: No inventory was parsed, only implicit localhost is available No -i and no inventory set in ansible.cfg, or the path is wrong Pass -i path or set inventory = in ansible.cfg; confirm with ansible-inventory --graph
Host lands in ungrouped unexpectedly It was listed before any [group] header (INI), or not nested under a group (YAML) Move it under the intended group; verify with --graph
when: enabled runs even though enabled=false INI made it the string "false", which is truthy Define the var in group_vars/host_vars YAML as a real boolean, or compare explicitly
A group_vars/ file is silently ignored Filename does not match the group name, wrong extension, or it is beside the wrong root Name it exactly <group>.yml; place group_vars/ beside the inventory or the playbook
Shell error or wrong hosts from a pattern Unquoted : ! * ~ interpreted by the shell Single-quote the whole pattern
Two groups set the same var; the “wrong” one wins Sibling groups at equal depth — tie broken unpredictably Set ansible_group_priority (higher wins) or stop defining the var in two siblings
--limit selects nothing The limit host is not in the play’s hosts: set (limit intersects) Widen the play’s hosts: or pick a host that is actually a member
Range web[1:10] produced web1 not web01 Missing zero-padding in the range Use web[01:10] to preserve padding
A random README/.swp in an inventory directory breaks parsing Ansible tried to parse a non-inventory file Keep only inventory files in the directory, or rely on inventory_ignore_extensions

Best practices

Security notes

Interview & exam questions

  1. What is the difference between the all and ungrouped groups? all contains every host in the inventory; ungrouped contains only hosts that belong to no group other than all. Both are implicit (Ansible always creates them) and both can carry vars (group_vars/all, group_vars/ungrouped).

  2. A variable is set in group_vars/production, group_vars/webservers, and host_vars/web1. web1 is a webserver in production. Which value wins? host_vars/web1. Precedence among these is host_vars > child-group vars (webservers) > parent-group vars (production) > group_vars/all.

  3. Why might when: enabled execute even though you set enabled=false in an INI inventory? Because INI inline values are strings by default, and the non-empty string "false" is truthy. Define the variable as a real boolean in YAML group_vars/host_vars, or compare explicitly (when: enabled | bool).

  4. Write a pattern for “all webservers that are also in production, except web5.” 'webservers:&production:!web5' — intersection first, then exclusion, single-quoted to protect :, &, and ! from the shell.

  5. What does --limit do, and what is the gotcha? It intersects with the play’s hosts: to further restrict the target set — it does not replace it. If you --limit to a host that is not in the play’s selection, zero hosts run.

  6. Where does Ansible look for group_vars/ and host_vars/ directories? In two places: adjacent to the inventory source and adjacent to the playbook. Filenames must match the group/host name; a name may be a single .yml file or a directory whose files are all merged in lexical order.

  7. How do you express a group of groups, and what does the parent contain? INI: [parent:children] listing child group names. YAML: a children: key. The parent transitively contains every host of every descendant group.

  8. You pass -i inventories/prod/ (a directory) and two files set the same group var. Which wins? The file parsed later in lexical order. Teams prefix files with numbers (01-, 99-) to make this deterministic.

  9. Give the range syntax for web01web10 versus web1web10, and the difference. web[01:10] preserves zero-padding (web01web10); web[1:10] does not (web1web10). Match your real hostnames.

  10. What is an alias, and which variable backs it? An inventory name decoupled from the connection address. You set ansible_host to the real IP/DNS name; the alias is used in patterns and var files while Ansible connects to ansible_host.

  11. Two sibling groups at the same depth both set x. How do you make one win deterministically? Set ansible_group_priority on the preferred group (default 1; higher wins). Better: avoid defining the same variable in two siblings.

  12. Which command shows the resolved hierarchy as a tree, and which dumps everything as JSON? ansible-inventory --graph (tree, add --vars for variables); ansible-inventory --list (full JSON; --yaml for YAML).

Quick check

  1. Name the two groups that exist in every inventory without being declared.
  2. In INI, is http_port=8080 an integer or a string by default — and why does it matter?
  3. Write the pattern for “everything in production except the host db1.”
  4. A variable conflicts between group_vars/all and host_vars/web1. Which applies to web1?
  5. What is the difference between passing -i twice and pointing -i at a directory?

Answers

  1. all (every host) and ungrouped (hosts in no other group).
  2. A string ("8080"). It matters because string values bypass numeric comparisons and the string "false" is truthy in when: tests — define typed vars in YAML group_vars/host_vars instead.
  3. 'production:!db1' (single-quoted to protect : and !).
  4. host_vars/web1 wins — host vars are more specific than group_vars/all.
  5. Both merge sources, but a directory parses every file inside it in lexical order (later files win on conflicts), whereas repeated -i flags add the exact sources you name in the order given.

Exercise

Build a two-environment inventory under inventories/ (prod/ and staging/), each with its own hosts file and group_vars/. In each environment define the groups webservers, dbservers, and a child group app = (webservers + dbservers). Set env in group_vars/all per environment (prod / staging), set log_level: info on webservers, and override it to debug on exactly one host via host_vars/. Then:

  1. Prove with ansible-inventory -i inventories/prod/ --graph --vars that the child relationships and variables resolved as intended.
  2. Run an ad-hoc ansible.builtin.debug against 'app:&webservers:!<one-host>' in prod and confirm the selected set is what you predicted.
  3. Show that --limit against a host not in app selects zero hosts.

Stretch goal: split group_vars/all into a directory (group_vars/all/10-network.yml, 20-logging.yml) and verify the merged result is unchanged.

Certification mapping

This lesson maps directly to the Red Hat Certified Engineer (RHCE) EX294 objectives that involve inventory and host selection:

The same concepts underpin Ansible Automation Platform inventory sources and credentials, so the knowledge transfers directly to AAP/AWX, where these static inventories become managed inventory objects.

Glossary

Next steps

ansibleinventorygroup-varshost-varspatternsrhce
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments