Chapter 6: Variables
Learning Objectives
By the end of this chapter, you will be able to:
- Understand what Ansible variables are and why they make playbooks flexible and reusable
- Learn the 22 levels of variable precedence (simplified to the ones you will encounter most)
- Define variables in playbooks, inventory files,
group_vars/,host_vars/, and via command line - Use magic variables and registered variables to capture and reuse task output
- Apply variable syntax correctly inside templates and task arguments
Explanation
In this chapter, you will learn how to use variables to make your playbooks smarter and more flexible. By the end, you will know how to store values, reuse them across tasks, and handle situations where variables might not be defined. Let's get started!
Variables are the backbone of flexible Ansible playbooks. As you learned in Chapter 5, a playbook maps tasks to hosts. Variables let you customize settings so the same playbook can target different environments (dev, staging, production) without rewriting it.
Think of a variable as a labeled box. You write a label on the box (that's the variable name), put something inside (that's the value), and later you can open the box to get what you stored. When Ansible runs, it looks up the label, finds the value inside, and uses that value in your tasks.
Defining Variables in a Playbook
The most common way to define variables is directly inside a play using the vars keyword:
---
- name: Configure web server
hosts: webservers
vars:
app_version: "2.1.0"
http_port: 8080
enabled: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Configure nginx port
ansible.builtin.lineinfile:
path: /etc/nginx/nginx.conf
regexp: "^listen"
line: "listen {{ http_port }};"
In this example, app_version, http_port, and enabled are variables defined at the play level. The task Configure nginx port references {{ http_port }} — Ansible substitutes 8080 when it runs.
Variable Precedence (Simplified)
Variable precedence means the order in which Ansible decides which value to use when the same variable is defined in multiple places. Think of it like a tie-breaker: if you set http_port in two different locations, which one wins? Ansible has 22 levels of precedence to handle this.
The general rule is: the most specific source wins. Here are the precedence levels you will encounter most, ordered from lowest to highest priority:
- Role defaults (
roles/myrole/defaults/main.yml) — lowest, meant to be overridden - Inventory vars (from your inventory file, defined at group or host level)
- Group variables (
group_vars/directory) - Host variables (
host_vars/directory) - Play vars (variables defined directly in the play with
vars:) - Play vars_files (
vars_files:directive) - Register variables (variables captured from task output with
register) - Extra vars (
-eor--extra-varson the command line) — highest priority
Pro Tip: In production, you will often use
group_vars/andhost_vars/for environment-specific configuration andextra_varsfor one-off overrides at runtime. Remember that extra vars always win — this is useful for emergency overrides but can be dangerous if used carelessly.
Inventory Variables
Variables can be defined directly in your inventory file. Recall from Chapter 4 that we had groups and hosts. You can attach variables to them:
# inventory/hosts
[webservers]
web01.example.com http_port=8080
web02.example.com http_port=9090
[webservers:vars]
nginx_version="1.18.0"
In YAML inventory format:
---
all:
children:
webservers:
hosts:
web01.example.com:
http_port: 8080
web02.example.com:
http_port: 9090
vars:
nginx_version: "1.18.0"
group_vars/ and host_vars/
Group variables (stored in the group_vars/ folder) let you define variables once for an entire group of servers. Host variables (stored in the host_vars/ folder) let you set variables for a single specific server.
Ansible looks for variable files in these directories at the same level as your inventory file. This approach keeps your inventory file clean and is the recommended way to manage environment-specific variables.
inventory/
├── hosts
├── group_vars/
│ ├── all.yml # Variables for ALL groups
│ ├── webservers.yml # Variables for webservers group
│ └── databases.yml # Variables for databases group
└── host_vars/
├── web01.example.com.yml
└── web02.example.com.yml
# inventory/group_vars/webservers.yml
---
nginx_version: "1.18.0"
app_user: www-data
max_connections: 2048
Extra Variables (--extra-vars)
Extra vars (short for "extra variables") are variables you pass directly on the command line when running a playbook. They have the highest precedence of all variable sources, which means they always win — useful for one-off overrides.
You can override any variable at runtime using the -e or --extra-vars flag:
You can also pass a YAML or JSON file:
Pro Tip: Using
-e @file.ymlis common in CI/CD pipelines where you generate an override file from secrets management (like HashiCorp Vault, AWS Secrets Manager, or CyberArk) before running your playbook.
Magic Variables
Magic variables are built-in variables that Ansible automatically provides. You do not need to define them — they are always available and contain useful information about your inventory and the current execution context.
| Variable | Description |
|---|---|
hostvars |
Access variables for all hosts in the inventory |
groups |
Dictionary of all groups in the inventory (group names mapped to lists of hosts) |
inventory_hostname |
The hostname of the current host as defined in inventory |
ansible_facts |
Discovered facts about the current host (CPU, memory, OS, etc.) |
play_hosts |
List of hosts in the current play (excluding those limited out) |
role_names |
List of role names applied to the current host |
Example using inventory_hostname:
- name: Configure per-host settings
hosts: webservers
tasks:
- name: Display the current host
ansible.builtin.debug:
msg: "Configuring {{ inventory_hostname }}"
- name: Check memory on this host
ansible.builtin.debug:
msg: "Memory: {{ ansible_facts['memtotal_mb'] }} MB"
Example using hostvars to reference another host's variable:
- name: Configure load balancer
hosts: loadbalancers
tasks:
- name: Get webserver IP from hostvars
ansible.builtin.debug:
msg: "Backend server: {{ hostvars['web01.example.com']['ansible_facts']['default_ipv4']['address'] }}"
Pro Tip:
ansible_factsare gathered automatically at the start of every play (unless you setgather_facts: no). These facts contain a wealth of information about your target hosts — OS version, CPU count, network interfaces, mounted disks, and much more. Always run with facts gathering enabled unless you have a specific reason not to (it adds a small amount of time but saves huge debugging effort).Note: To use
hostvarsto access another host'sansible_facts, those facts must have been gathered in a previous play (or via fact caching). If Ansible has not yet gathered facts for the target host, theansible_factskey will be empty or undefined.
Registered Variables
Register is a keyword that captures the output of a task and stores it in a variable. When a task runs, it produces a result (success/failure, output, whether it made changes). You can capture that result using register and then use it in subsequent tasks.
- name: Check disk space
hosts: webservers
tasks:
- name: Get disk usage
ansible.builtin.command: df -h
register: disk_output
- name: Display disk usage
ansible.builtin.debug:
var: disk_output.stdout_lines
- name: Alert if /dev/sda1 is nearly full
ansible.builtin.debug:
msg: "Disk usage is high!"
when: disk_output.stdout is search('/dev/sda1')
A registered variable contains the entire result object, not just the output. The object includes:
stdout— standard output as a stringstdout_lines— standard output as a list (one line per element)stderr— standard error as a stringrc— return code (0 usually means success)changed— boolean indicating whether the task made changes
Variable Syntax in Detail
Ansible uses Jinja2 (a templating language) for variable expressions. You write variables inside double curly braces {{ variable_name }}, and Ansible replaces them with the actual value when the playbook runs.
Inside a Jinja2 expression, the variable evaluates to its value. But you can also use filters (covered in Chapter 9) and tests:
# Basic variable substitution
msg: "Running {{ app_version }}"
# With a filter (uppercase)
msg: "Server {{ inventory_hostname }} is {{ ansible_facts['distribution'] | upper }}"
# With a default value (if the variable is undefined)
msg: "Config path: {{ config_path | default('/etc/myapp.conf') }}"
# Quoting: always quote the entire expression when it starts a string
# WRONG (unquoted): line: {{ http_port }} is the port # YAML sees it as a key: value pair
# RIGHT (quoted): line: "{{ http_port }} is the port" # Ansible substitutes the value
Pro Tip: Always quote task arguments that start with
{{to prevent YAML from misinterpreting the curly braces. Ansible will warn you if you forget, but getting in the habit of quoting prevents confusing errors.
Examples
Example 1: Multi-Environment Deployment
# site.yml
---
- name: Deploy application to {{ env }} environment
hosts: webservers
vars:
env: dev
app_path: /opt/myapp
tasks:
- name: Create application directory
ansible.builtin.file:
path: "{{ app_path }}"
state: directory
owner: www-data
mode: '0755'
- name: Deploy application files
ansible.builtin.copy:
src: files/myapp/
dest: "{{ app_path }}/"
Run against dev: ansible-playbook site.yml
PLAY [Deploy application to dev environment] *********************************
TASK [Gathering Facts] *********************************************************
ok: [webserver1]
TASK [Create application directory] *******************************************
changed: [webserver1]
TASK [Deploy application files] ***********************************************
changed: [webserver1]
PLAY RECAP *********************************************************************
webserver1 : ok=3 changed=2 unreachable=0 failed=0
Run against prod: ansible-playbook site.yml -e "env=prod"
PLAY [Deploy application to prod environment] *********************************
TASK [Gathering Facts] *********************************************************
ok: [webserver1]
TASK [Create application directory] *******************************************
changed: [webserver1]
TASK [Deploy application files] ***********************************************
changed: [webserver1]
PLAY RECAP *********************************************************************
webserver1 : ok=3 changed=2 unreachable=0 failed=0
Example 2: Registered Variable for Conditional Restart
# configure-db.yml
---
- name: Configure PostgreSQL
hosts: databases
vars:
max_db_connections: 100
shared_buffers: 256MB
tasks:
- name: Update postgresql.conf
ansible.builtin.lineinfile:
path: /etc/postgresql/14/main/postgresql.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: "^max_connections", line: "max_connections = {{ max_db_connections }}" }
- { regexp: "^shared_buffers", line: "shared_buffers = {{ shared_buffers }}" }
register: pg_conf_changed
- name: Restart PostgreSQL
ansible.builtin.service:
name: postgresql
state: restarted
when: pg_conf_changed is changed
- name: Verify PostgreSQL is running
ansible.builtin.service:
name: postgresql
state: started
enabled: yes
Example 3: Hostvars to Build a Hosts File
set_fact is a module that creates a new variable and sets its value. Unlike registered variables (which capture task output), set_fact lets you build up custom variables from existing data.
# generate-hosts-file.yml
---
- name: Generate /etc/hosts entries
hosts: localhost
gather_facts: no
tasks:
- name: Build hosts file content
ansible.builtin.set_fact:
hosts_content: |
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
- name: Add all inventory hosts
ansible.builtin.set_fact:
hosts_content: "{{ hosts_content }}\n{{ hostvars[item]['ansible_facts']['default_ipv4']['address'] }} {{ item }}"
loop: "{{ groups['all'] }}"
when: hostvars[item]['ansible_facts']['default_ipv4'] is defined
- name: Write hosts file
ansible.builtin.copy:
content: "{{ hosts_content }}\n"
dest: /tmp/hosts-generated
Hands-On Exercises
Exercise 1: Variable Precedence Demo
Objective: Demonstrate that extra vars override everything, and play-level vars override role defaults.
Instructions:
1. Create a role scaffold: ansible-galaxy init --init-path roles demo-role
2. In roles/demo-role/defaults/main.yml, set: message: "from defaults"
3. In roles/demo-role/tasks/main.yml, add a task: debug: msg: "{{ message }}"
4. Create a playbook that applies the role and also sets vars: { message: "from play" }
5. Run the playbook and observe: ansible-playbook test-precedence.yml
PLAY [Test variable precedence] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [demo-role : debug] ******************************************************
ok: [localhost] => {
"msg": "from play"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0
- Now run with
ansible-playbook test-precedence.yml -e 'message="from extra vars"'
PLAY [Test variable precedence] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [demo-role : debug] ******************************************************
ok: [localhost] => {
"msg": "from extra vars"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0
Expected Outcome: First run prints "from play", second run prints "from extra vars".
Hint: Use ansible-playbook -v for more detail on which value Ansible resolved for each variable.
Exercise 2: Inventory Variables and group_vars
Objective: Use group_vars to configure different settings for dev vs. prod environments.
Instructions:
1. Create an inventory file with two groups: dev_servers and prod_servers, each with one host.
2. Create group_vars/dev_servers.yml with deploy_env: dev and log_level: debug
3. Create group_vars/prod_servers.yml with deploy_env: prod and log_level: error
4. Create group_vars/all.yml with app_name: mywebapp
5. Write a playbook that targets both groups and uses debug to display all three variables.
6. Run the playbook with -l dev_servers and observe which values are resolved.
PLAY [Configure servers] ******************************************************
TASK [Gathering Facts] *********************************************************
ok: [devserver1]
TASK [Display environment configuration] ***************************************
ok: [devserver1] => {
"msg": "Environment: dev, Log Level: debug, App: mywebapp"
}
PLAY RECAP *********************************************************************
devserver1 : ok=2 changed=0 unreachable=0 failed=0
Expected Outcome: When targeting dev_servers, deploy_env=dev, log_level=debug, app_name=mywebapp. When targeting prod_servers, deploy_env=prod, log_level=error, app_name=mywebapp.
Hint: Variables from all.yml apply to every host. The more specific group variable (dev_servers vs all) takes precedence for the same key.
Exercise 3: Register and Reuse Task Output
Objective: Capture command output and use it in a subsequent task.
Instructions:
1. Create a playbook targeting localhost.
2. Run date command and register the output as current_date.
3. Register the output of whoami as current_user.
4. Use a debug task to display both: "Current date: [date], running as: [user]"
PLAY [System information] *****************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Get current date] ********************************************************
changed: [localhost]
TASK [Get current user] ********************************************************
changed: [localhost]
TASK [Display system information] *********************************************
ok: [localhost] => {
"msg": "Current date: Thu Jun 13 10:30:45 UTC 2026, running as: nkydigitech"
}
TASK [Security check] **********************************************************
ok: [localhost] => {
"msg": "Running with elevated privileges"
}
PLAY RECAP *********************************************************************
localhost : ok=5 changed=2 unreachable=0 failed=0
- Add a conditional: only show a message if the user is
rootornkydigitech.
Expected Outcome: The playbook runs successfully on your current user, displaying the current date and username. If run as a different user, the conditional message is suppressed.
Hint: Registered variables contain .stdout, .stdout_lines, .stderr, and .rc. Use .stdout_lines for line-based output and .stdout for the full string.
Exercise 4: Magic Variables with hostvars
Objective: Use hostvars and groups to dynamically reference other hosts.
Instructions:
1. Create an inventory with at least 2 hosts in the webservers group.
2. In a playbook, target localhost (or one webserver).
3. Use debug to print:
- A list of all hosts in the webservers group
- The ansible_facts['hostname'] of each webserver (using hostvars[item])
4. Use a loop over groups['webservers'] to print each host's memory.
PLAY [Display webserver information] ******************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [List all webservers] *****************************************************
ok: [localhost] => {
"msg": "Webservers group: ['web01.example.com', 'web02.example.com']"
}
TASK [Display hostnames] ********************************************************
ok: [localhost] => {
"msg": "web01.example.com hostname: web01"
}
ok: [localhost] => {
"msg": "web02.example.com hostname: web02"
}
TASK [Display memory for each webserver] ***************************************
ok: [localhost] => {
"msg": "web01.example.com memory: 2048 MB"
}
ok: [localhost] => {
"msg": "web02.example.com memory: 4096 MB"
}
PLAY RECAP *********************************************************************
localhost : ok=5 changed=0 unreachable=0 failed=0
Expected Outcome: The playbook outputs information about all webservers, even though the play only targets one host. This demonstrates that hostvars lets you query facts from hosts not currently running in the play.
Hint: When looping over groups['webservers'], each item is a hostname string. Use hostvars[item] to access that host's variables and facts.
Exercise 5: Default Values with Jinja2 Filters
Objective: Handle undefined variables gracefully using the default filter.
Instructions:
1. Create a playbook with a task that references a variable db_port that is NOT defined anywhere.
2. Run the playbook and observe the error.
PLAY [Test undefined variable] ************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Connect to database] *****************************************************
fatal: [localhost]: FAILED! => {"msg": "The task includes an option to use the undefined variable db_port"}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=1 skipped=0
{{ db_port | default(5432) }}.
4. Run again — the task should succeed, using 5432.
PLAY [Test default filter] ****************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Connect to database] *****************************************************
ok: [localhost] => {
"msg": "Connecting to database on port 5432"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0
db_port via -e "db_port=5433" and verify the override wins.
PLAY [Test default filter] ****************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Connect to database] *****************************************************
ok: [localhost] => {
"msg": "Connecting to database on port 5433"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0
Expected Outcome: First run succeeds with default 5432. Second run uses override 5433. This pattern is essential for writing playbooks that work across environments where some variables may not be defined.
Hint: The default filter is one of the most used Jinja2 filters in Ansible. Always use it for variables that might not be defined in all environments.
Summary
- Ansible variables let you customize playbooks using variables, making them reusable across environments and hosts.
- Variable precedence determines which value wins when the same variable is defined in multiple places.
- Variables can be defined in playbooks, inventory,
group_vars/,host_vars/, and via--extra-vars. - Magic variables (
hostvars,groups,inventory_hostname,ansible_facts) are always available. - Registered variables capture task results for use in subsequent tasks.
- Always quote expressions starting with
{{to avoid YAML parsing errors. - Use the
defaultfilter to handle undefined variables gracefully.
Additional Resources
-
Ansible Documentation: Variables: https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html Official documentation covering all variable topics.
-
Ansible Documentation: Understanding Variable Precedence: https://docs.ansible.com/ansible/latest/tips_tricks/ansible_tips_tricks.html#setting-different-values-for-different-hosts Explains the 22 levels with practical examples.
-
Ansible by Red Hat — Variables Deep Dive (Blog): https://www.ansible.com/blog/ansible-best-practices-variables Real-world patterns for managing variables at scale.
```md
```