Home DevOps Ansible Variables Deep Dive: From Inventory to Extra-Vars, Facts, and Debugging
Intermediate 8 min · 2026-06-21

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..

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Production
production tested
July 16, 2026
last updated
2,406
articles · all by Naren
Before you start⏱ 25 min
  • Solid grasp of DevOps fundamentals
  • Comfortable with command-line tools
  • Basic Linux administration knowledge
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Ansible variables store dynamic values (e.g., hostnames, paths) and can be defined in inventory, playbooks, roles, or facts. Facts are system-level variables gathered automatically by the setup module. The key takeaway: always use | default() to avoid undefined variable errors, and cache facts with gather_facts: no when not needed to speed up playbooks.

✦ Definition~90s read
What is Ansible Variables and Facts?

Ansible variables are named values that parameterize your automation. They allow you to write plays that adapt to different environments, hosts, or conditions. Variables can be defined in many places: inventory files (host_vars, group_vars), playbooks (vars, vars_files), roles (defaults/main.yml, vars/main.yml), command-line extra-vars, and dynamically via register and set_fact.

Think of Ansible variables like sticky notes you put on a server.

Facts are a special kind of variable automatically gathered by Ansible when gather_facts: true (default). They contain system information like OS distribution, network interfaces, memory, and CPU. Facts are stored in the ansible_facts dictionary and can be accessed like any other variable (e.g., ansible_facts['os_family']).

The gather_facts mechanism runs the setup module on each target host, which collects a large amount of data.

The variable system solves the problem of hardcoding values. Without variables, you'd need separate playbooks for each environment. With variables, you use the same playbook and inject environment-specific values. The challenge is managing precedence: when the same variable is defined in multiple places, which value wins? Understanding the precedence order is critical to avoid surprises.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

site.ymlYAML
1
2
3
4
5
6
7
8
9
---
- name: Demonstrate variable types
  hosts: all
  vars:
    playbook_var: "defined in play"
  tasks:
    - name: Show variable precedence
      ansible.builtin.debug:
        msg: "{{ playbook_var }}"
Output
PLAY [Demonstrate variable types] **************************************************
TASK [Show variable precedence] ************************************************
ok: [localhost] => {
"msg": "defined in play"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
⚠ Variable Precedence Pitfall
Extra-vars override everything, including role vars. If you use -e for debugging, ensure you don't accidentally override production values. Use --extra-vars @file.json to keep overrides trackable.
📊 Production Insight
In a recent incident, a team member used -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.
🎯 Key Takeaway
Variable precedence is a ladder; know where each type stands. Use inventory for environment config, role defaults for fallbacks, and extra-vars sparingly.
ansible-variables-facts Ansible Variable Sources Stack Layered hierarchy of variable definitions Extra Vars --extra-vars | CLI override Dynamic Vars set_fact | register | hostvars Playbook Vars vars | vars_files | vars_prompt Inventory Vars host_vars | group_vars Role Defaults defaults/main.yml THECODEFORGE.IO
thecodeforge.io
Ansible Variables Facts

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.

inventory/hostsINI
1
2
3
4
5
6
7
8
9
10
11
[web]
web1 ansible_host=192.168.1.10
web2 ansible_host=192.168.1.11

[web:vars]
http_port=80

# host_vars/web1.yml
# ---
# ansible_host: 192.168.1.10
# http_port: 8080
🔥Dynamic Inventory Variables
Cloud inventory plugins (e.g., aws_ec2) can tag hosts with variables. Use ansible-inventory --graph to visualize host groupings.
📊 Production Insight
We once had a production outage because a host_vars file had a typo: 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.
🎯 Key Takeaway
Inventory variables are the foundation; use host_vars for exceptions, group_vars for common config. Always validate with 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 ``

playbook.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
---
- name: Playbook variables demo
  hosts: localhost
  connection: local
  vars:
    greeting: "Hello from vars"
  vars_files:
    - vars/external.yml
  vars_prompt:
    - name: user_name
      prompt: "Enter your name"
      private: false
  tasks:
    - name: Show variables
      ansible.builtin.debug:
        msg: "{{ greeting }} {{ user_name }} {{ external_var }}"
Output
Enter your name: Alice
PLAY [Playbook variables demo] *************************************************
TASK [Show variables] **********************************************************
ok: [localhost] => {
"msg": "Hello from vars Alice from external file"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
⚠ vars_prompt in Automation
vars_prompt requires interactive TTY. In Jenkins or Ansible Tower, it will hang indefinitely. Use ansible-vault or environment variables instead.
📊 Production Insight
We used 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.
🎯 Key Takeaway
Use vars_files for external config, avoid vars_prompt in automation. Encrypt sensitive files with ansible-vault.
ansible-variables-facts Role defaults vs Role vars When to use each in Ansible roles defaults/main.yml vars/main.yml Precedence Lowest, easily overridden Higher, overrides inventory Use case Sensible defaults for users Internal constants, not for override Override method Any other variable source Only extra vars or set_fact Documentation Documented as configurable Often hidden from users Example nginx_port: 80 internal_api_key: 'abc123' THECODEFORGE.IO
thecodeforge.io
Ansible Variables Facts

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.

Role defaults (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 ``
Role vars (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.

roles/example/defaults/main.ymlYAML
1
2
3
4
---
# defaults/main.yml
role_default_var: "default value"
role_override_var: "default value"
💡Role Variable Documentation
Use meta/argument_specs.yml (Ansible 2.11+) to define role variable types and defaults. This enables validation and auto-documentation.
📊 Production Insight
A junior engineer put a critical API key in 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.
🎯 Key Takeaway
Use 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'}" ``

Use cases
  • 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.

playbook.ymlBASH
1
ansible-playbook site.yml --extra-vars "my_var=override_value"
⚠ Extra-Vars in CI/CD
Never use plaintext extra-vars for secrets in CI/CD logs. Use --extra-vars @secrets.yml with vault encryption or CI/CD secret injection.
📊 Production Insight
We had a CI/CD pipeline that passed -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.
🎯 Key Takeaway
Extra-vars are powerful but dangerous. Use them only for temporary overrides and validate with --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 }}"
  • ```
Accessing output
  • 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.

register.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
---
- name: Register task output
  hosts: localhost
  connection: local
  tasks:
    - name: Run shell command
      ansible.builtin.shell: echo "Hello from shell"
      register: shell_result

    - name: Show registered variable
      ansible.builtin.debug:
        var: shell_result.stdout
Output
PLAY [Register task output] ****************************************************
TASK [Run shell command] *******************************************************
changed: [localhost]
TASK [Show registered variable] ************************************************
ok: [localhost] => {
"shell_result.stdout": "Hello from shell"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
🔥Register and Idempotency
register does not affect idempotency by itself. It just stores output. Use changed_when and failed_when to control task status.
📊 Production Insight
We used 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.
🎯 Key Takeaway
Use 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 }}" ``

Use cases
  • 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.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
---
- name: Set fact dynamically
  hosts: localhost
  connection: local
  tasks:
    - name: Calculate sum
      ansible.builtin.set_fact:
        sum_result: "{{ 3 + 4 }}"

    - name: Show dynamic variable
      ansible.builtin.debug:
        var: sum_result
Output
PLAY [Set fact dynamically] ****************************************************
TASK [Calculate sum] ***********************************************************
ok: [localhost]
TASK [Show dynamic variable] ***************************************************
ok: [localhost] => {
"sum_result": "7"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
💡set_fact and Caching
set_fact variables are not cached across runs. Use fact caching (e.g., jsonfile) if you need persistence.
📊 Production Insight
We used 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.
🎯 Key Takeaway
Use 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'] }}" ``

Limitations
  • 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.

hostvars.ymlYAML
1
2
3
4
5
6
7
---
- name: Cross-host communication
  hosts: all
  tasks:
    - name: Show IP of other host
      ansible.builtin.debug:
        msg: "{{ hostvars['web1']['ansible_default_ipv4']['address'] }}"
Output
PLAY [Cross-host communication] ***********************************************
TASK [Show IP of other host] ***************************************************
ok: [web2] => {
"msg": "192.168.1.10"
}
ok: [web1] => {
"msg": "192.168.1.10"
}
PLAY RECAP *********************************************************************
web1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
⚠ hostvars Availability
hostvars for a host are only populated after that host has been visited in a play. Use gather_facts: true on all hosts early in the playbook to ensure facts are available.
📊 Production Insight
We tried to use 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: [].
🎯 Key Takeaway
hostvars enables cross-host data access, but ensure the target host is processed first. Use a fact-gathering play for all hosts.

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).

Common facts
  • 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.

facts.ymlYAML
1
2
3
4
5
6
7
---
- name: Gather and use facts
  hosts: all
  tasks:
    - name: Show OS family
      ansible.builtin.debug:
        msg: "{{ ansible_facts['os_family'] }}"
Output
PLAY [Gather and use facts] ***************************************************
TASK [Show OS family] **********************************************************
ok: [localhost] => {
"msg": "Debian"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
🔥Fact Caching
Use 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.
📊 Production Insight
We managed 500 servers. Fact gathering added 5 minutes per playbook run. We implemented fact caching with 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.
🎯 Key Takeaway
Facts are powerful but expensive. Cache them or disable gathering when not 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.

When to disable
  • 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.

no_facts.ymlYAML
1
2
3
4
5
6
7
8
---
- name: Skip fact gathering
  hosts: all
  gather_facts: false
  tasks:
    - name: This task does not use facts
      ansible.builtin.debug:
        msg: "No facts gathered"
Output
PLAY [Skip fact gathering] ****************************************************
TASK [This task does not use facts] ********************************************
ok: [localhost] => {
"msg": "No facts gathered"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
⚠ Facts Disabled but Required
If your role depends on facts (e.g., ansible_os_family), the playbook will fail with undefined variable errors. Always test with --check after disabling facts.
📊 Production Insight
We disabled 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.
🎯 Key Takeaway
Disable facts for speed, but ensure no tasks depend on them. Use selective 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.

Common uses
  • debug: var=variable_name prints 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!".

debug.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
---
- name: Debug variable inspection
  hosts: localhost
  connection: local
  vars:
    my_var: "test value"
  tasks:
    - name: Print variable
      ansible.builtin.debug:
        var: my_var
    - name: Print with message
      ansible.builtin.debug:
        msg: "The value is {{ my_var }}"
Output
PLAY [Debug variable inspection] **********************************************
TASK [Print variable] **********************************************************
ok: [localhost] => {
"my_var": "test value"
}
TASK [Print with message] ******************************************************
ok: [localhost] => {
"msg": "The value is test value"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
💡Debugging with Tags
Add tags: [debug] to debug tasks. Run with ansible-playbook --tags debug to see only debug output.
📊 Production Insight
During a complex multi-role playbook, we added 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.
🎯 Key Takeaway
Use 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:

  1. Reproduce with verbosity: Run the playbook with -vvv to see variable resolution.
  2. ```bash
  3. ansible-playbook playbook.yml -i inventory -vvv | grep 'variable'
  4. ```
  5. Dump all variables: Use a debug task to dump hostvars.
  6. ```yaml
  7. - name: Dump host variables
  8. debug:
  9. var: hostvars[inventory_hostname]
  10. tags: [never, debug]
  11. ```
  12. Check precedence: Use ansible-inventory to see inventory variables.
  13. ```bash
  14. ansible-inventory -i inventory --list --yaml
  15. ```
  16. Test with extra-vars: Override the suspect variable to see if it changes behavior.
  17. ```bash
  18. ansible-playbook playbook.yml -e 'suspect_var=test_value'
  19. ```
  20. Use --syntax-check: Validates variable references.
  21. ```bash
  22. ansible-playbook playbook.yml --syntax-check
  23. ```
  24. Check fact cache: If using caching, clear it.
  25. ```bash
  26. ansible-playbook playbook.yml --flush-cache
  27. ```
  28. Isolate the issue: Create a minimal playbook that only prints the variable.
  29. ```yaml
  30. - hosts: target_host
  31. gather_facts: false
  32. tasks:
  33. - debug:
  34. var: suspect_var
  35. ```

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.

debug_workflow.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
---
- name: Production debugging workflow
  hosts: all
  gather_facts: false
  vars:
    expected_version: "2.0"
  tasks:
    - name: Check installed version
      ansible.builtin.shell: cat /etc/myapp/version
      register: version_output
      ignore_errors: true

    - name: Debug version
      ansible.builtin.debug:
        msg: "Expected {{ expected_version }}, got {{ version_output.stdout | default('unknown') }}"

    - name: Assert version
      ansible.builtin.assert:
        that:
          - version_output.stdout == expected_version
        fail_msg: "Version mismatch!"
Output
PLAY [Production debugging workflow] ******************************************
TASK [Check installed version] *************************************************
changed: [web1]
TASK [Debug version] ***********************************************************
ok: [web1] => {
"msg": "Expected 2.0, got 2.0"
}
TASK [Assert version] **********************************************************
ok: [web1] => {
"changed": false,
"msg": "All assertions passed"
}
PLAY RECAP *********************************************************************
web1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
🔥Automated Variable Validation
Use ansible-lint with rules like var-naming and no-same-owner to catch variable issues early. Integrate into CI pipeline.
📊 Production Insight
We now include a pre-flight task in all playbooks that validates required variables are defined:
```yaml
- name: Validate required variables
fail:
msg: "Variable {{ item }} is not defined"
when: item not in vars
loop:
- app_version
- deploy_env
```
🎯 Key Takeaway
A systematic debugging workflow saves hours. Automate variable validation in your playbooks.
● Production incidentPOST-MORTEMseverity: high

The Silent Override: Role Defaults vs. Inventory

Symptom
Playbook ran without errors, but the application configuration was wrong on some hosts. The variable app_port was expected to be 8080 but was 9090 on affected hosts.
Assumption
The engineer assumed inventory variables take precedence over role defaults (they do, but only if the role variable is defined in vars/main.yml, not defaults/main.yml).
Root cause
The role defined 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.
Fix
Removed the redundant app_port from the playbook vars block. The inventory value then took effect. Verified with ansible-inventory --list --yaml and ansible-playbook --check.
Key lesson
  • Always audit all variable definition locations.
  • Use debug module to print variable values during playbook runs.
  • Maintain a variable precedence cheat sheet near your desk.
Production debug guideSymptom → Root cause → Fix4 entries
Symptom · 01
Variable 'app_port' has unexpected value 9090 instead of 8080
Fix
Check variable precedence: inventory vars (lowest), playbook vars, role defaults, role vars, set_fact/register, extra-vars (highest). Use 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.
Symptom · 02
Playbook takes 2 minutes per host due to fact gathering
Fix
Set 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.
Symptom · 03
register variable appears empty or undefined
Fix
Check the task's output: register: 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.
Symptom · 04
hostvars returns undefined for another host
Fix
Ensure the other host is in the inventory and has been reached in the play. Use hostvars['hostname'] — hostname must match inventory name exactly. Use debug: var=hostvars to see all available hosts.
★ Ansible Variables and Facts Quick Referenceprint this for your desk
Variable value unexpected
Immediate action
Check all definition locations
Commands
ansible-inventory --list --yaml
ansible-playbook --syntax-check -e 'var=override'
Fix now
Use debug: var=myvar in playbook
Playbook too slow due to facts+
Immediate action
Add gather_facts: false
Commands
time ansible-playbook playbook.yml
time ansible-playbook playbook.yml -e 'ansible_facts={}'
Fix now
Set gather_facts: false at play level
register variable empty+
Immediate action
Inspect task output
Commands
ansible-playbook playbook.yml -v
ansible -m debug -a 'var=result'
Fix now
Access with result.stdout not result
hostvars undefined for host+
Immediate action
Verify host in inventory
Commands
ansible-inventory --list --yaml | grep hostname
ansible hostname -m debug -a 'var=hostvars[inventory_hostname]'
Fix now
Ensure host is in inventory and play includes it
Facts stale or missing+
Immediate action
Clear fact cache
Commands
rm -rf /tmp/ansible_fact_cache/*
ansible-playbook playbook.yml --flush-cache
Fix now
Set fact_caching_timeout: 0 to disable cache
Variable Precedence Reference Table
SourcePrecedence LevelScopeExample Definition
Role defaults1 (lowest)Roleroles/role/defaults/main.yml
Inventory group_vars2Groupgroup_vars/all.yml
Inventory host_vars3Hosthost_vars/web1.yml
Playbook vars4Playvars: block in playbook
Playbook vars_files5Playvars_files: [file.yml]
Role vars6Roleroles/role/vars/main.yml
Block vars7Blockvars: within block
Task vars8Taskvars: within task
set_fact9Host (dynamic)set_fact: myvar=value
register10Host (dynamic)register: myvar
Extra-vars (CLI)11 (highest)Global-e 'myvar=value'
⚙ Quick Reference
12 commands from this guide
FileCommand / CodePurpose
site.yml- name: Demonstrate variable types1. Variable Types
inventoryhosts[web]2. Inventory Variables
playbook.yml- name: Playbook variables demo3. Playbook Variables
rolesexampledefaultsmain.ymlrole_default_var: "default value"4. Role Variables
playbook.ymlansible-playbook site.yml --extra-vars "my_var=override_value"5. Extra-Vars
register.yml- name: Register task output6. The register Keyword
set_fact.yml- name: Set fact dynamically7. The set_fact Module
hostvars.yml- name: Cross-host communication8. The hostvars Magic Variable
facts.yml- name: Gather and use facts9. Gathered Facts (ansible_facts)
no_facts.yml- name: Skip fact gathering10. gather_facts
debug.yml- name: Debug variable inspection11. The debug Module
debug_workflow.yml- name: Production debugging workflow12. Putting It All Together

Key takeaways

1
Variable precedence is critical
role defaults < inventory < playbook vars < role vars < set_fact/register < extra-vars.
2
Use ansible-inventory --list --yaml to dump all inventory variables.
3
Disable gather_facts for performance, but ensure no tasks depend on facts.
4
Use register to capture task output and set_fact for computed variables.
5
hostvars enables cross-host data access; ensure target hosts are processed first.
6
Use debug module with verbosity to inspect variables without cluttering output.
7
Extra-vars override everything; use sparingly and validate in CI/CD.
8
Automate variable validation with fail tasks to catch undefined variables early.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the variable precedence order in Ansible?
Q02JUNIOR
How do you access the stdout of a registered variable?
Q03SENIOR
What is the difference between role defaults and role vars?
Q04SENIOR
How can you access a variable from another host?
Q05SENIOR
What is the performance impact of gather_facts: true?
Q06JUNIOR
How do you debug a variable that has an unexpected value?
Q07SENIOR
What is the difference between set_fact and register?
Q08SENIOR
How do you make a set_fact variable available across hosts?
Q01 of 08JUNIOR

What is the variable precedence order in Ansible?

ANSWER
From lowest to highest: role defaults, inventory group_vars, inventory host_vars, playbook vars, playbook vars_files, role vars, block vars, task vars, set_fact/register, extra-vars. Extra-vars always win.
FAQ · 8 QUESTIONS

Frequently Asked Questions

01
What is the difference between ansible_facts and hostvars?
02
Can I use extra-vars to override role vars?
03
How do I see all variables for a host?
04
What is the best practice for storing secrets in Ansible?
05
How do I conditionally run a task based on a register variable?
06
What is fact caching and how do I enable it?
07
How do I create a custom fact?
08
Why is my set_fact variable not available in the next play?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
July 16, 2026
last updated
2,406
articles · all by Naren
🔥

That's Ansible. Mark it forged?

8 min read · try the examples if you haven't

Previous
Ansible Inventory Management
5 / 37 · Ansible
Next
Ansible Conditionals and Loops