Introduction

The AnsibleUndefinedVariable error is one of the most common Ansible errors. It occurs when a playbook references a variable that hasn't been defined anywhere — inventory, group_vars, host_vars, role defaults, or task vars. This guide shows every way to fix, prevent, and debug undefined variable errors.

The Error

fatal: [webserver1]: FAILED! => {
    "msg": "The task includes an option with an undefined variable.
    The error was: 'my_variable' is undefined.
    The error appears to be in '/home/user/playbook.yml': line 8, column 7"
}

Why It Happens

# 1. Variable never defined anywhere
- name: This fails
  ansible.builtin.debug:
    msg: "{{ app_port }}"  # Where is app_port defined? Nowhere.

# 2. Typo in variable name
  vars:
    application_port: 8080
- name: Typo
  ansible.builtin.debug:
    msg: "{{ applicaton_port }}"  # Missing 'i' — silent until runtime

# 3. Variable defined for wrong host/group
# group_vars/webservers.yml defines 'nginx_port'
# But task runs on host in 'databases' group — no nginx_port

# 4. Conditional variable
- name: Only set in some cases
  ansible.builtin.set_fact:
    result_path: "/tmp/output"
  when: generate_output | default(false)

- name: Fails if generate_output was false
  ansible.builtin.copy:
    src: "{{ result_path }}"  # Undefined if skipped above
    dest: /opt/output

Fix 1: The default Filter

The most common fix — provide a fallback value:

# Simple default
- name: Use default value
  ansible.builtin.debug:
    msg: "Port is {{ app_port | default(8080) }}"

# Default empty string
- name: Optional variable
  ansible.builtin.debug:
    msg: "Extra args: {{ extra_args | default('') }}"

# Default empty list
- name: Optional list
  ansible.builtin.debug:
    msg: "Packages: {{ extra_packages | default([]) }}"

# Default empty dict
- name: Optional dict
  ansible.builtin.user:
    name: "{{ user.name }}"
    groups: "{{ user.groups | default(omit) }}"

# Chained defaults
- name: First defined wins
  ansible.builtin.debug:
    msg: "{{ custom_port | default(env_port) | default(8080) }}"

# default(omit) — skip the parameter entirely
- name: Optional parameter
  ansible.builtin.apt:
    name: nginx
    state: "{{ nginx_state | default(omit) }}"
    # If nginx_state is undefined, 'state' parameter is omitted

Fix 2: Conditional Checks

# Check if defined before using
- name: Only if variable exists
  ansible.builtin.debug:
    msg: "{{ special_config }}"
  when: special_config is defined

# Ternary with defined check
- name: Conditional value
  ansible.builtin.set_fact:
    config_path: "{{ custom_path if custom_path is defined else '/etc/default/app.conf' }}"

# Block with when
- name: Optional configuration block
  when: advanced_config is defined
  block:
    - name: Deploy advanced config
      ansible.builtin.template:
        src: advanced.conf.j2
        dest: /etc/app/advanced.conf

    - name: Restart service
      ansible.builtin.systemd:
        name: app
        state: restarted

Fix 3: Mandatory Variables

Sometimes you want the error — to fail fast when required variables are missing:

# Fail with clear message
- name: Validate required variables
  ansible.builtin.assert:
    that:
      - db_host is defined
      - db_password is defined
      - app_domain is defined
    fail_msg: "Required variables missing. Set db_host, db_password, and app_domain."

# mandatory filter — fails with clear error
- name: Must be defined
  ansible.builtin.debug:
    msg: "Database: {{ db_host | mandatory }}"
    # Error: "Mandatory variable 'db_host' not defined"

# Role argument_specs (Ansible 2.11+)
# roles/webapp/meta/argument_specs.yml
argument_specs:
  main:
    short_description: Deploy web application
    options:
      app_domain:
        type: str
        required: true
        description: Domain name for the application
      app_port:
        type: int
        required: false
        default: 8080
        description: Port to listen on

Fix 4: Variable Precedence

# Ansible checks 22 levels of precedence (highest wins):
# 1.  extra vars (-e)                    ← HIGHEST
# 2.  include params
# 3.  role params
# 4.  set_facts / registered vars
# 5.  task vars
# 6.  block vars
# 7.  role vars (roles/x/vars/main.yml)
# 8.  play vars_files
# 9.  play vars_prompt
# 10. play vars
# 11. host_vars (host_vars/hostname.yml)
# 12. group_vars (most specific group)
# 13. group_vars (parent groups)
# 14. group_vars/all
# 15. inventory host_vars
# 16. inventory group_vars
# 17. inventory file vars
# 18. role defaults (roles/x/defaults/main.yml)  ← LOWEST
# 19. command line values

# Common mistake: defining in role defaults but overriding with empty
# roles/webapp/defaults/main.yml
app_port: 8080  # Good default

# group_vars/staging.yml
app_port:  # Oops — this sets it to None, not "use default"

Debugging Undefined Variables

# Debug all variables for a host
- name: Show all variables
  ansible.builtin.debug:
    var: vars
  # Or specific namespaces:
  # var: hostvars[inventory_hostname]
  # var: group_names
  # var: ansible_facts

# Check where a variable comes from
- name: Variable origin
  ansible.builtin.debug:
    msg: |
      app_port value: {{ app_port | default('UNDEFINED') }}
      app_port type: {{ app_port | default('') | type_debug }}

# Run with verbose to see variable loading
# ansible-playbook playbook.yml -vvv

# List all variables for a host
# ansible -m debug -a "var=hostvars[inventory_hostname]" webserver1

Nested Variable Errors

# Nested dict access — common trap
- name: Fails if 'database' or 'host' key missing
  ansible.builtin.debug:
    msg: "{{ config.database.host }}"

# Fix: Use default at each level
- name: Safe nested access
  ansible.builtin.debug:
    msg: "{{ (config | default({})).get('database', {}).get('host', 'localhost') }}"

# Or use the dict default pattern
- name: Cleaner nested default
  ansible.builtin.debug:
    msg: "{{ config.database.host | default('localhost') }}"
  when: config is defined and config.database is defined

# Best fix: Flatten in role defaults
# roles/webapp/defaults/main.yml
db_host: localhost
db_port: 5432
db_name: app
# Instead of nested: config.database.host

Common Mistakes

# ❌ MISTAKE 1: default(false) vs default(False)
# Both work, but be consistent
- name: Boolean default
  ansible.builtin.debug:
    msg: "{{ enable_debug | default(false) }}"

# ❌ MISTAKE 2: Forgetting that registered vars are host-scoped
- name: Run on first host
  ansible.builtin.command: hostname
  register: result
  run_once: true

- name: Fails on other hosts
  ansible.builtin.debug:
    msg: "{{ result.stdout }}"
  # FIX: Use hostvars[groups['all'][0]].result.stdout

# ❌ MISTAKE 3: Variable in when clause
- name: Wrong — Jinja2 in when
  ansible.builtin.debug:
    msg: "hello"
  when: "{{ my_var }}"  # Don't use {{ }} in when
# FIX:
  when: my_var | default(false)

# ❌ MISTAKE 4: Undefined in template
# template.j2: Server={{ server_name }}
# If server_name is undefined, template task fails
# FIX: Use default in template:
# Server={{ server_name | default('localhost') }}

Prevent Undefined Variables Globally

# ansible.cfg
[defaults]
# Make undefined variables an error (default behavior)
error_on_undefined_vars = true

# Or for development, make them warnings
# error_on_undefined_vars = false
# (Variables render as empty string instead of failing)

Conclusion

Undefined variable errors have three fixes: default() filter for optional variables, is defined checks for conditional logic, and mandatory filter or argument_specs for required variables. Most errors come from typos, wrong host/group scope, or missing role defaults. Use ansible-playbook -vvv to trace where variables are loaded from, and use assert tasks to validate required variables early in your playbook.