Surviving Missing Variables

Scenario: A play renders fine on web01 and dies on web02 with "'upstream_timeout' is undefined". The variable is defined in host_vars/web01.yml and nowhere else.

New words, in plain English

A template that references an undefined variable fails the task for that host. That is good default behaviour - silence would be worse - but it means every optional value needs an explicit decision.

There are exactly three correct answers, and choosing among them is a design decision rather than a syntax question:

  1. It has a sensible fallback - use {{ retries | default(3) }}. The template itself carries the default, so no variable needs defining anywhere.
  2. It varies by tier and must always exist - define it in group_vars/, where it applies to the whole group, and let a host_vars/ entry override it for genuine exceptions. This is the fix for the scenario: upstream_timeout: 15 in group_vars/webservers.yml, with web01's 30 kept as its documented exception.
  3. It is genuinely required and there is no safe default - use {{ api_token | mandatory }} so the run fails immediately with a clear message, rather than writing an empty credential into a live config file.

The trap is reaching for default('') everywhere. It stops the error, and it replaces a loud failure with a silent misconfiguration - a service that starts and behaves wrongly, which is far harder to diagnose than one that refuses to start.

Analogy: An undefined variable is a blank on a form. default(3) is a pre-printed sensible answer. Defining it in group_vars is the department filling it in for everyone. mandatory is the form refusing to be submitted. default('') is quietly erasing the question and filing it anyway.

A worked example

# The template makes optionality explicit
timeout = {{ upstream_timeout }}          # required - must be defined somewhere
retries = {{ retries | default(3) }}      # optional - template supplies default
ciphers = {{ tls_ciphers | join(':') }}   # must be a LIST, not a string
token   = {{ api_token | mandatory }}     # fail loudly if absent

# group_vars/webservers.yml - the tier-wide value
upstream_timeout: 15
tls_ciphers:
  - HIGH
  - '!aNULL'
  - '!MD5'

# host_vars/web01.yml - a documented exception
# web01 sits behind a slow legacy payments gateway.
upstream_timeout: 30

default(x, true) is worth knowing separately: plain default only substitutes when a variable is undefined, so a variable explicitly set to an empty string passes straight through. {{ upstream | default('backend:8080', true) }} also substitutes for empty, false and zero - which is usually what you actually meant.

Ansible also offers strict undefined behaviour for templates. Leaving it strict (the default) is right: a template that renders a missing value as blank produces a config file that is syntactically valid and semantically wrong, and the failure surfaces much later and much further away.

Warning: default('') converts a clear error into a silently broken config file. Use it only where an empty value is genuinely correct.
Goal: Put this to work in the ansible-template-defaults lab. Open /labs/ansible, pick ansible-template-defaults, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.