Introduction

Variables make playbooks reusable — define values once, use them everywhere. Ansible variables work in playbooks, templates, inventories, and command line. Understanding where to define them and how precedence works is key to writing clean automation.

Define Variables

In a Playbook

---
- name: Deploy application
  hosts: webservers
  vars:
    app_name: myapp
    app_port: 8080
    app_env: production
    app_user: deploy
  tasks:
    - name: Show config
      ansible.builtin.debug:
        msg: "Deploying {{ app_name }} on port {{ app_port }}"

In a Vars File

# vars/production.yml
app_name: myapp
app_port: 8080
app_env: production
db_host: db.internal
db_port: 5432
db_name: myapp_prod

# playbook.yml
---
- hosts: webservers
  vars_files:
    - vars/production.yml
  tasks:
    - debug:
        msg: "DB: {{ db_host }}:{{ db_port }}/{{ db_name }}"

In Inventory (group_vars / host_vars)

project/
├── inventory/
│   ├── production
│   ├── group_vars/
│   │   ├── all.yml           ← All hosts
│   │   ├── webservers.yml    ← Web server group
│   │   └── dbservers.yml     ← DB server group
│   └── host_vars/
│       ├── web-01.yml        ← Specific host
│       └── db-01.yml
# group_vars/all.yml — applies to all hosts
ntp_server: time.google.com
dns_servers:
  - 8.8.8.8
  - 8.8.4.4

# group_vars/webservers.yml
nginx_worker_processes: 4
app_port: 8080

# host_vars/web-01.yml
nginx_worker_processes: 8    # Override for this specific host

On the Command Line

# Extra vars (highest precedence!)
ansible-playbook site.yml -e "app_version=2.1.0 app_env=staging"

# JSON format
ansible-playbook site.yml -e '{"app_version": "2.1.0", "debug_mode": true}'

# From a file
ansible-playbook site.yml -e @vars/deploy.yml

With set_fact (Runtime)

- name: Set variable at runtime
  ansible.builtin.set_fact:
    app_url: "https://{{ app_domain }}:{{ app_port }}"
    total_memory_gb: "{{ ansible_memtotal_mb / 1024 | round(1) }}"

With register (Task Output)

- name: Get current version
  ansible.builtin.command: /opt/app/version
  register: version_result

- debug:
    msg: "Current version: {{ version_result.stdout }}"

Use Variables

# In task parameters
- ansible.builtin.template:
    src: "{{ app_name }}.conf.j2"
    dest: "/etc/{{ app_name }}/config.conf"
    owner: "{{ app_user }}"

# In conditions
- ansible.builtin.service:
    name: nginx
    state: started
  when: install_nginx | default(true)

# In loops
- ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
  loop: "{{ users }}"

# In templates (Jinja2)
# server {
#     listen {{ app_port }};
#     server_name {{ app_domain }};
#     root {{ app_root }};
# }

Variable Precedence (Low to High)

From lowest to highest priority:

  1. Role defaults (roles/x/defaults/main.yml)
  2. Inventory file group vars
  3. Inventory group_vars/all
  4. Inventory group_vars/<group>
  5. Inventory file host vars
  6. Inventory host_vars/<host>
  7. Play vars
  8. Play vars_files
  9. Play vars_prompt
  10. Task vars
  11. set_fact / register
  12. Role vars (roles/x/vars/main.yml)
  13. Block vars
  14. Include params
  15. Extra vars (-e) ← ALWAYS WINS

In practice, remember three levels:

  • Defaults (role defaults) — easily overridden
  • Standard (group_vars, host_vars, play vars) — normal usage
  • Force (extra vars -e) — always wins, use for CLI overrides

Data Types

# String
app_name: myapp
app_name: "my app"        # Quotes for special chars

# Number
app_port: 8080
timeout: 30.5

# Boolean
debug_mode: true
ssl_enabled: false

# List
packages:
  - nginx
  - redis
  - postgresql
# Or inline: packages: [nginx, redis, postgresql]

# Dictionary
database:
  host: db.internal
  port: 5432
  name: myapp
  credentials:
    user: admin
    password: "{{ vault_db_password }}"

# Access dict values
# {{ database.host }}
# {{ database['port'] }}
# {{ database.credentials.user }}

Default Values

# Use default filter to prevent undefined errors
- debug:
    msg: "Port: {{ app_port | default(8080) }}"

# Default for boolean
- service:
    name: nginx
    state: started
  when: install_nginx | default(true)

# Default empty list
- user:
    name: "{{ item }}"
  loop: "{{ extra_users | default([]) }}"

# Default empty string
- debug:
    msg: "Prefix: {{ prefix | default('') }}"

Common Filters

# Type conversion
port: "{{ input_port | int }}"
enabled: "{{ input_flag | bool }}"
items: "{{ input_csv | split(',') }}"

# String manipulation
lower: "{{ name | lower }}"
upper: "{{ name | upper }}"
replaced: "{{ path | replace('old', 'new') }}"
trimmed: "{{ input | trim }}"

# List operations
first: "{{ servers | first }}"
last: "{{ servers | last }}"
count: "{{ servers | length }}"
joined: "{{ servers | join(',') }}"
unique: "{{ tags | unique }}"
sorted: "{{ names | sort }}"

# Dict operations
keys: "{{ config | dict2items }}"
merged: "{{ defaults | combine(overrides) }}"

# Math
rounded: "{{ value | round(2) }}"
absolute: "{{ number | abs }}"
max_val: "{{ [10, 20, 5] | max }}"

Practical Patterns

Environment-Specific Config

group_vars/
├── all.yml              # Shared defaults
├── production.yml       # Production overrides
└── staging.yml          # Staging overrides
# group_vars/all.yml
app_port: 8080
log_level: info
replicas: 1

# group_vars/production.yml
log_level: warn
replicas: 3
ssl_enabled: true

# group_vars/staging.yml
log_level: debug
replicas: 1
ssl_enabled: false

Conditional Variables

vars:
  # OS-specific package names
  nginx_package: "{{ 'nginx' if ansible_os_family == 'Debian' else 'nginx' }}"
  php_package: "{{ 'php-fpm' if ansible_os_family == 'Debian' else 'php-fpm' }}"
  
  # Environment-based settings
  worker_count: "{{ ansible_processor_vcpus * 2 }}"
  cache_size: "{{ (ansible_memtotal_mb * 0.25) | int }}m"

Vault-Encrypted Variables

# group_vars/production/vault.yml (encrypted)
vault_db_password: supersecret
vault_api_key: abc123

# group_vars/production/vars.yml (references vault)
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"
# Encrypt
ansible-vault encrypt group_vars/production/vault.yml

# Run with vault
ansible-playbook site.yml --ask-vault-pass

Troubleshooting

"undefined variable"

# Check if defined before using
- debug:
    msg: "{{ my_var }}"
  when: my_var is defined

# Or use default
- debug:
    msg: "{{ my_var | default('fallback') }}"

Variable Not Overriding

Check precedence. extra_vars (-e) always wins. Role defaults are easily overridden. If a variable seems stuck, check if it's set at a higher precedence level:

# Debug: show where a variable comes from
ansible -m debug -a "var=hostvars[inventory_hostname]" web-01

String vs Number

# YAML auto-types: 8080 is an int, "8080" is a string
port: 8080       # int
port: "8080"     # string

# Force type with filters
- debug:
    msg: "{{ port | int + 1 }}"

Conclusion

Variables are defined in playbooks (vars), files (vars_files), inventory (group_vars/host_vars), runtime (set_fact/register), or CLI (-e). Extra vars (-e) always win. Use group_vars/ for environment-specific config, host_vars/ for host-specific overrides, and role defaults for sensible fallbacks. Always provide | default() for optional variables to prevent undefined errors. Encrypt sensitive values with Ansible Vault.