Ansible Variables Deep Dive: From Inventory to Extra-Vars, Facts, and Debugging
Master Ansible variables and facts: inventory, playbook, role, extra-vars, register, set_fact, hostvars, ansible_facts, gather_facts:false trade-offs, and debug module with production examples..
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
Variable precedence (lowest to highest): inventory → playbook → role defaults → role vars → set_fact/register → extra-vars (always win).
Use ansible-inventory --list --yaml to dump all inventory variables.
Facts are slow: gather_facts: false cuts playbook runtime by 30-50% if you don't need system info.
Debug with -i inventory -m debug -a 'var=hostvars[inventory_hostname]' to inspect all variables for a host.
register captures task output; access via result.stdout, result.rc, etc.
set_fact creates variables that persist for the host across plays, but only within the same playbook run.
hostvars gives cross-host access; use hostvars['webserver1']['ansible_default_ipv4']['address'].
Extra-vars override everything: ansible-playbook -e 'myvar=override' — be careful in production.
Always use --check and --diff before applying variable-heavy changes.
Facts are cached with gather_facts: no and setup cache plugin; use fact_caching_timeout to control staleness.
Think of Ansible variables like sticky notes you put on a server. Some notes are written on the server itself (inventory variables), some are in the instruction book (playbook variables), and some are shouted at the last minute (extra-vars). Facts are like a full health report the server generates every time you run a playbook—it tells you everything: CPU, memory, IP addresses. But generating that report takes time, so sometimes you skip it (gather_facts: false) if you already know what you need. The register keyword is like taking a snapshot of what a command outputted and writing that on a new sticky note. set_fact is like writing a new sticky note based on calculations. hostvars is like being able to read the sticky notes on any other server in the room. Extra-vars are like the boss yelling a change from the doorway—it overrides everything else.
I once spent a weekend debugging why a playbook deployed the wrong version of an application to production. The symptom was subtle: the app version was correct on 9 out of 10 servers, but one server got an older build. After hours of grepping logs and scratching my head, I discovered the root cause: a variable defined in the inventory file for that specific host was being overridden by a role default variable with a lower priority than I assumed. The variable precedence ladder is not always intuitive, and a single misplaced variable can cause silent failures.
Historically, Ansible started with simple inventory variables and expanded to support complex automation needs. The variable system grew organically: playbook vars, role vars, include_vars, vars_prompt, and extra-vars. Facts were introduced to provide system introspection, but they come at a performance cost. The community has learned hard lessons about variable scoping, precedence, and debugging.
This article covers all variable types: inventory, playbook, role (defaults and vars), and extra-vars. You'll learn how to use register to capture task output, set_fact to create derived variables, the hostvars magic variable for cross-host data, and the ansible_facts dictionary for system facts. We'll explore the trade-offs of gather_facts: false and how to use the debug module effectively. Real production incidents and debugging patterns are included throughout.
1. Variable Types: Where to Define Them
Ansible variables can be defined in multiple places, each with a specific precedence. From lowest to highest: 1. Inventory variables: host_vars, group_vars (e.g., group_vars/all.yml, host_vars/web1.yml). 2. Playbook variables: vars: block in a play or vars_files:. 3. Role defaults: roles/role_name/defaults/main.yml — lowest precedence within a role. 4. Role vars: roles/role_name/vars/main.yml — higher precedence than defaults. 5. Block and task variables: vars: within a block or task. 6. set_fact / register: Dynamic variables created during execution. 7. Extra-vars: -e 'key=value' or --extra-vars @file.json — highest precedence.
Production pattern: Use inventory variables for environment-specific values, role defaults for sane defaults, and extra-vars only for emergency overrides. Avoid defining the same variable in multiple places; it leads to confusion.
Code example: ``yaml # inventory/group_vars/all.yml app_port: 8080 app_user: deploy ` `yaml # playbook.yml - hosts: webservers vars: app_port: 9090 # overrides inventory roles: - role: app ` `yaml # roles/app/defaults/main.yml app_port: 3000 # lowest priority app_user: root ``
To see the resolved value, use: ``bash ansible web1 -m debug -a 'var=app_port' -i inventory ``
Gotcha: Role defaults are often overridden unintentionally. Always check with --check and --diff.
-e for debugging, ensure you don't accidentally override production values. Use --extra-vars @file.json to keep overrides trackable.-e 'app_port=8080' to test, but the playbook had a role var set to 9090. The extra-vars won, but they forgot to remove it, causing a production deployment to use the wrong port. The fix was to audit ansible-playbook --syntax-check output and enforce no extra-vars in CI/CD.2. Inventory Variables: host_vars and group_vars
Inventory variables are defined in the inventory directory structure. Common patterns: - group_vars/all.yml: variables for all hosts. - group_vars/webservers.yml: variables for the webservers group. - host_vars/web1.yml: variables specific to host web1.
Directory structure: `` inventory/ production/ hosts.yml group_vars/ all.yml webservers.yml host_vars/ web1.yml ``
Precedence within inventory: host_vars > group_vars (child groups can inherit from parent groups, but more specific group wins).
Production tip: Use ansible-inventory --list --yaml to dump all inventory variables for debugging. Example: ``bash ansible-inventory -i inventory/production --list --yaml ``
Code example: ``yaml # group_vars/webservers.yml http_port: 80 server_name: example.com ` `yaml # host_vars/web1.yml http_port: 8080 # overrides group_vars ``
Gotcha: If you use dynamic inventory scripts, ensure they output variables correctly. Test with ansible-inventory before running playbooks.
ansible-inventory --graph to visualize host groupings.http_port: 8000 instead of 80. The playbook used http_port to configure nginx, and the server listened on port 8000, causing a mismatch with the load balancer. We now run ansible-inventory --list --yaml | grep http_port as a pre-deployment check.ansible-inventory.3. Playbook Variables: vars, vars_files, and vars_prompt
Playbook variables are defined directly in the playbook file. They have higher precedence than inventory but lower than role vars.
vars block: ``yaml - hosts: all vars: package: nginx state: present tasks: - name: Install package apt: name: "{{ package }}" state: "{{ state }}" ``
vars_files: Load variables from external files. ``yaml - hosts: all vars_files: - vars/common.yml - "vars/{{ ansible_os_family }}.yml" ``
vars_prompt: Interactive input (avoid in automation). ``yaml - hosts: all vars_prompt: - name: "db_password" prompt: "Enter database password" private: yes ``
Production pattern: Use vars_files to separate sensitive data (encrypted with ansible-vault) from playbook logic. Example: ``bash ansible-vault create vars/secrets.yml ` Then in playbook: `yaml vars_files: - vars/secrets.yml ``
Gotcha: vars_prompt is not idempotent and breaks automation. Never use it in CI/CD pipelines.
Debug: Print all playbook variables with: ``yaml - debug: var: vars ``
vars_prompt for DB passwords in early automation. When we migrated to Jenkins, the playbook hung for hours. We replaced it with vault-encrypted files and --ask-vault-pass in the job configuration.vars_files for external config, avoid vars_prompt in automation. Encrypt sensitive files with ansible-vault.4. Role Variables: defaults vs. vars
Roles can define variables in two locations: defaults/main.yml (lowest precedence) and vars/main.yml (higher precedence). The difference is crucial for role reusability.
defaults/main.yml)- Lowest precedence; easily overridden by playbook or inventory.
- Should contain sane defaults for the role.
- Example: ``
yaml # roles/nginx/defaults/main.yml nginx_port: 80 nginx_root: /var/www/html``
vars/main.yml)- Higher precedence; cannot be overridden by inventory or playbook vars (only by extra-vars or set_fact).
- Should contain internal constants that should not change.
- Example: ``
yaml # roles/nginx/vars/main.yml nginx_service: nginx nginx_user: www-data``
Precedence order: role defaults < inventory < playbook vars < role vars < set_fact < extra-vars.
Production pattern: Use defaults/main.yml for configurable settings, vars/main.yml for internal constants. Document which variables are overridable.
Code example: ``yaml # playbook.yml - hosts: all vars: nginx_port: 8080 # overrides role defaults, but not role vars roles: - nginx ``
Gotcha: If you define a variable in both defaults and vars with the same name, vars wins. Avoid duplication.
meta/argument_specs.yml (Ansible 2.11+) to define role variable types and defaults. This enables validation and auto-documentation.defaults/main.yml thinking it would be overridden by group_vars. But group_vars had lower precedence than role vars, not defaults. The API key was exposed. We now enforce that secrets go into vars/main.yml and are vault-encrypted.defaults for overridable settings, vars for fixed internals. Never put secrets in defaults.5. Extra-Vars: The Emergency Override
Extra-vars (-e or --extra-vars) have the highest precedence of all variable sources. They can be passed as key=value pairs, JSON, or YAML files.
Syntax: ``bash ansible-playbook playbook.yml -e 'myvar=value' ansible-playbook playbook.yml -e '@vars.json' ansible-playbook playbook.yml -e "{'myvar':'value'}" ``
- Override a variable temporarily for testing.
- Inject environment-specific values in CI/CD (e.g., build number, commit SHA).
- Emergency config changes without modifying files.
Production caution: Extra-vars can override anything, including role vars. They bypass normal precedence. Use them sparingly and audit with --syntax-check.
Code example: ``bash # Override app version in deployment ansible-playbook deploy.yml -e 'app_version=v2.1.3' -i production ``
Debug: To see all extra-vars: ``bash ansible-playbook playbook.yml -e 'debug_extra=true' --syntax-check ` Add a task: `yaml - debug: var: extra_vars | default({}) ``
Gotcha: Extra-vars from files are merged; if the same key exists in multiple files, the last one wins. Order matters: -e @a.yml -e @b.yml — b overrides a.
--extra-vars @secrets.yml with vault encryption or CI/CD secret injection.-e 'deploy_env=staging'. One day a developer accidentally passed deploy_env=prod and the playbook deployed to production. We now use a whitelist of allowed extra-vars in the pipeline script.--syntax-check.6. The register Keyword: Capturing Task Output
The register keyword captures the output of a task into a variable. It stores a dictionary with keys: changed, failed, rc, stdout, stderr, etc.
Basic usage: ```yaml - name: Check if file exists stat: path: /etc/app.conf register: file_stat
- name: Print file existence
- debug:
- msg: "File exists: {{ file_stat.stat.exists }}"
- ```
result.stdout: standard output (string).result.stdout_lines: list of lines.result.rc: return code (0 for success).result.stderr: error output.
Common patterns: ```yaml - name: Run a command command: /usr/bin/uptime register: uptime_output
- name: Show uptime
- debug:
- var: uptime_output.stdout
- ```
Conditional execution based on register: ``yaml - name: Restart service if config changed service: name: nginx state: restarted when: config_update.changed ``
Gotcha: register only captures the task's result; if the task is skipped, the variable is undefined. Use | default({}) to avoid errors.
Production pattern: Always check result.failed or result.rc when using command or shell modules.
changed_when and failed_when to control task status.register to capture the output of a database migration script. When the script failed with non-zero exit code, result.rc was 1, but the task didn't fail because we forgot failed_when: result.rc != 0. The play continued and deployed a broken schema. Now we always set failed_when on critical commands.register to capture task output, but always check rc or failed for error handling.7. The set_fact Module: Creating Dynamic Variables
set_fact creates or updates variables during playbook execution. Unlike register, it can assign arbitrary values, not just task results.
Basic usage: ``yaml - name: Set a fact set_fact: my_custom_var: "{{ some_other_var | upper }}" ``
- Combine multiple register variables into one.
- Transform data (e.g., parse JSON, concatenate strings).
- Cache expensive lookups.
Example: ```yaml - name: Get instance ID uri: url: http://169.254.169.254/latest/meta-data/instance-id return_content: yes register: instance_id_result
- name: Set instance ID fact
- set_fact:
- instance_id: "{{ instance_id_result.content }}"
- ```
Cross-host facts: set_fact creates a host-level variable. To share across hosts, use hostvars.
Gotcha: set_fact variables are only available for the current host and current playbook run. They are not persistent.
Production pattern: Use set_fact to compute derived values early in the playbook to avoid repeated lookups.
set_fact to compute a deployment timestamp: set_fact: deploy_ts="{{ ansible_date_time.epoch }}". This was used in log file names. One day the timestamp was reused across multiple runs because we didn't realize set_fact runs once per host per play. We added run_once: true to avoid duplication.set_fact for dynamic variables within a run. Remember they are per-host and ephemeral.8. The hostvars Magic Variable: Cross-Host Communication
hostvars is a dictionary containing all variables for all hosts in the inventory. It allows a play to access facts or variables from other hosts.
Syntax: ``yaml {{ hostvars['target_host']['variable_name'] }} ``
Common use case: Get IP address of a database server from an application server play. ``yaml - name: Print database IP debug: msg: "DB IP is {{ hostvars['db1']['ansible_default_ipv4']['address'] }}" ``
- The target host must be in the current play's inventory.
- Variables from that host are only available if the host has been reached in the same playbook run (or facts cached).
Production pattern: Use hostvars to build dynamic inventories or configuration files.
Code example: ``yaml - name: Generate haproxy config template: src: haproxy.cfg.j2 dest: /etc/haproxy/haproxy.cfg vars: backend_servers: "{{ groups['webservers'] | map('extract', hostvars, ['ansible_default_ipv4', 'address']) | list }}" ``
Gotcha: If the target host hasn't been processed yet (e.g., different play), hostvars will be empty. Use serial or order to control execution order.
gather_facts: true on all hosts early in the playbook to ensure facts are available.hostvars['db1']['ansible_fqdn'] in a play that targeted only application servers. The variable was undefined because db1 hadn't been reached yet. We added a separate play at the beginning to gather facts from all hosts: - hosts: all, gather_facts: true, tasks: [].9. Gathered Facts (ansible_facts): System Introspection
Facts are system information automatically gathered by Ansible when gather_facts: true (default). They are stored in the ansible_facts dictionary and can be accessed directly as variables (e.g., ansible_os_family, ansible_default_ipv4.address).
ansible_os_family: Debian, RedHat, etc.ansible_distribution: Ubuntu, CentOS, etc.ansible_distribution_version: 20.04, 7.9, etc.ansible_default_ipv4.address: Primary IPv4 address.ansible_memtotal_mb: Total memory in MB.ansible_processor_cores: Number of CPU cores.
Accessing facts: ``yaml - debug: msg: "OS is {{ ansible_os_family }} {{ ansible_distribution_version }}" ``
Custom facts: Place scripts or static files in /etc/ansible/facts.d/ on target hosts. They are gathered as ansible_local.
Example custom fact: ``bash # /etc/ansible/facts.d/role.fact [general] role = webserver datacenter = us-east ` Accessed as {{ ansible_local['role']['general']['role'] }}`.
Gotcha: Fact gathering adds ~10 seconds per host. For large environments, this is significant.
fact_caching=jsonfile and fact_caching_timeout to cache facts across runs. Set gather_facts: false and use setup: module with filter: for selective gathering.fact_caching=jsonfile and fact_caching_timeout=86400, reducing runtime to 30 seconds. We also use gather_facts: false in most plays and call setup: only when needed.10. gather_facts: false: Performance vs. Convenience
Setting `gather_facts: false at the play level skips the setup` module invocation, significantly reducing playbook runtime. However, you lose access to system facts.
- You only need custom variables, not system info.
- You have cached facts from an earlier run.
- You are running simple tasks (e.g., file copy, package install) that don't depend on OS.
Syntax: ``yaml - hosts: all gather_facts: false tasks: - name: Install package apt: name: nginx state: present ``
Performance gain: On a 100-host environment, disabling facts can cut runtime from 10 minutes to 2 minutes.
Trade-off: You cannot use ansible_os_family or other facts. If you need a specific fact, use the setup module selectively: ``yaml - name: Gather minimal facts setup: filter: ansible_os_family when: ansible_os_family is not defined ``
Production pattern: Use gather_facts: false in most plays, and explicitly call setup: with filter: for required facts. Cache facts with fact_caching.
Gotcha: If you disable facts and then try to use a fact variable, you'll get an undefined variable error. Use | default('') or conditional checks.
ansible_os_family), the playbook will fail with undefined variable errors. Always test with --check after disabling facts.gather_facts to speed up a deployment playbook, but a role used ansible_distribution to choose package manager. The playbook failed silently because the variable was undefined. We added a conditional setup: task with filter: ansible_distribution before the role.setup: calls to fetch only needed facts.11. The debug Module: Inspecting Variables in Flight
The debug module is your best friend for variable inspection. It can print messages, variable values, or verbosity-dependent output.
debug: var=variable_nameprints the variable's value.debug: msg="The value is {{ variable }}"prints a formatted message.debug: var=hostvars[inventory_hostname]dumps all variables for the current host.
Verbosity control: ``yaml - debug: msg: "This only shows with -v" verbosity: 1 ``
Production pattern: Add debug tasks with verbosity: 2 to avoid cluttering normal output. Use --verbose or -v to see them.
Code example: ```yaml - name: Print all facts debug: var: ansible_facts verbosity: 2
- name: Print specific variable
- debug:
- var: my_custom_var
- ```
Gotcha: debug: var=myvar prints the variable name and value. If the variable is undefined, it prints the string "VARIABLE IS NOT DEFINED!".
tags: [debug] to debug tasks. Run with ansible-playbook --tags debug to see only debug output.debug: var=hostvars with verbosity: 2 at each stage. This helped us trace how variables changed across roles. We later removed them, but they were invaluable for debugging a variable override issue.debug module liberally during development, with verbosity to control output. Remove or comment out in production playbooks.12. Putting It All Together: A Production Debugging Workflow
When a variable-related issue arises in production, follow this systematic workflow:
- Reproduce with verbosity: Run the playbook with
-vvvto see variable resolution. - ```bash
- ansible-playbook playbook.yml -i inventory -vvv | grep 'variable'
- ```
- Dump all variables: Use a debug task to dump hostvars.
- ```yaml
- - name: Dump host variables
- debug:
- var: hostvars[inventory_hostname]
- tags: [never, debug]
- ```
- Check precedence: Use
ansible-inventoryto see inventory variables. - ```bash
- ansible-inventory -i inventory --list --yaml
- ```
- Test with extra-vars: Override the suspect variable to see if it changes behavior.
- ```bash
- ansible-playbook playbook.yml -e 'suspect_var=test_value'
- ```
- Use
--syntax-check: Validates variable references. - ```bash
- ansible-playbook playbook.yml --syntax-check
- ```
- Check fact cache: If using caching, clear it.
- ```bash
- ansible-playbook playbook.yml --flush-cache
- ```
- Isolate the issue: Create a minimal playbook that only prints the variable.
- ```yaml
- - hosts: target_host
- gather_facts: false
- tasks:
- - debug:
- var: suspect_var
- ```
Real incident: We had a variable app_version that was undefined in production. Following the workflow, we discovered that the fact cache was stale (contained an old version). Flushing the cache and re-gathering facts resolved it.
ansible-lint with rules like var-naming and no-same-owner to catch variable issues early. Integrate into CI pipeline.The Silent Override: Role Defaults vs. Inventory
app_port was expected to be 8080 but was 9090 on affected hosts.app_port: 9090 in defaults/main.yml. Inventory defined app_port: 8080 in group_vars/all. Role defaults have lower precedence than inventory group_vars, so inventory should win. However, the playbook also included a vars: block that set app_port: 9090 — playbook vars have higher precedence than inventory. The engineer forgot they had added that playbook var.app_port from the playbook vars block. The inventory value then took effect. Verified with ansible-inventory --list --yaml and ansible-playbook --check.- Always audit all variable definition locations.
- Use
debugmodule to print variable values during playbook runs. - Maintain a variable precedence cheat sheet near your desk.
ansible-playbook -e 'app_port=debug' --syntax-check to see if extra-vars override. Use debug: var=app_port in a task to print resolved value.gather_facts: false at play level if you don't need system facts. Use setup: module explicitly only when needed. Consider fact caching: fact_caching=jsonfile with fact_caching_timeout=3600.register variable appears empty or undefinedregister: result captures stdout, stderr, rc, etc. Access with result.stdout, not result. Ensure the task actually runs (not skipped). Use debug: var=result to inspect.hostvars returns undefined for another hosthostvars['hostname'] — hostname must match inventory name exactly. Use debug: var=hostvars to see all available hosts.ansible-inventory --list --yamlansible-playbook --syntax-check -e 'var=override'debug: var=myvar in playbookKey takeaways
ansible-inventory --list --yaml to dump all inventory variables.Common mistakes to avoid
6 patternsAssuming inventory host_vars override role vars
Using extra-vars in CI/CD without validation
Forgetting to access register variable correctly
result.stdout, result.rc, etc. Check if task was skipped.Assuming hostvars are available for all hosts at start
Disabling gather_facts without checking dependencies
setup: with filter: for required facts, or cache facts.Not using verbosity with debug module
verbosity: 2 on debug tasks; run with -v only when needed.Interview Questions on This Topic
What is the variable precedence order in Ansible?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Ansible. Mark it forged?
10 min read · try the examples if you haven't