Ansible Error Guide: 'recursive loop detected in template string' — Fix Self-Referential Vars
Fix Ansible's 'recursive loop detected in template string' error by breaking self-referential variables, renaming shadowed vars, and restructuring group_vars.
- #ansible
- #automation
- #troubleshooting
- #errors
Fixing errors like this? Get 500 free DevOps AI prompts
500 copy-paste AI prompts for the stack you actually run — one PDF, free.
Overview
Ansible raises this when templating a variable eventually requires templating itself, creating a cycle Jinja2 cannot resolve:
fatal: [app01]: FAILED! => {"changed": false, "msg": "recursive loop detected in template string: {{ app_url }}"}
You will often see it in a variable definition where a name refers back to itself, directly or through another variable:
An unhandled exception occurred while templating '{{ base_path }}/{{ app_name }}'. Error was: recursive loop detected in template string
The task fails during variable resolution, before it does any work. The problem is not the host or the module — it is a self-referential variable graph that has no fixed point for Jinja2 to settle on.
Symptoms
- A task or
debugfails withrecursive loop detected in template stringnaming a specific variable. - The variable references itself, e.g.
path: "{{ path }}/bin". - Two variables reference each other, forming a cycle across
group_vars/host_vars. - A
default(...)filter references the same variable it is defaulting. - The error appears only after merging an inventory layer that redefines a name using its own prior value.
Common Root Causes
- Direct self-reference —
log_dir: "{{ log_dir }}/app"; the right-handlog_dirre-triggers templating of the same variable. - Mutual reference —
a: "{{ b }}"andb: "{{ a }}"across files. - Shadowing while extending — trying to “append” to a variable by redefining it in terms of itself in a higher-precedence layer.
- Self-referential default —
port: "{{ port | default(port) }}". - Loop var reusing the outer name — a
loop_varorset_factthat reuses a name it reads from. - Registered var re-templated — feeding a variable’s templated output back into a definition of the same variable.
Diagnostic Workflow
Find where the offending variable is defined across all precedence layers:
grep -rn "app_url" inventory/ group_vars/ host_vars/ roles/*/defaults/ roles/*/vars/
Trace the resolved value (or the failure) with a targeted debug and a verbose run:
ansible app01 -i inventory -m debug -a "var=app_url" -vvv
Break the cycle by introducing a distinct base variable that the derived one references — never itself:
# BROKEN: self-referential
# app_url: "{{ app_url }}/health"
# FIXED: derive from a separate base variable
app_base_url: "https://{{ app_host }}:{{ app_port }}"
app_health_url: "{{ app_base_url }}/health" # references a different name, no cycle
For a value you meant to extend across layers, rename rather than redefine in terms of itself:
# group_vars/all.yml
base_flags: "--verbose"
# group_vars/prod.yml
extra_flags: "--metrics"
run_flags: "{{ base_flags }} {{ extra_flags }}" # composed from two distinct names
Example Root Cause Analysis
A play failed with recursive loop detected in template string: {{ java_opts }}. The role’s defaults/main.yml set java_opts: "-Xmx512m", and a group_vars/prod.yml file tried to add a flag with java_opts: "{{ java_opts }} -XX:+UseG1GC". Because group_vars outranks role defaults, the higher-precedence java_opts referenced a java_opts that Ansible resolved to the same higher-precedence definition — a cycle with no base value to anchor it.
The fix separated the base from the extension: the role kept java_opts_default: "-Xmx512m", and group_vars/prod.yml set java_opts: "{{ java_opts_default }} -XX:+UseG1GC". Now the composed variable references a different name, the graph is acyclic, and each environment can extend the base without redefining a name in terms of itself. The tell for this whole class of bug is a variable whose right-hand side mentions its own name.
Prevention Best Practices
- Never define a variable in terms of itself; derive from a separately named base variable instead.
- Keep a clear base-plus-derived naming convention (
x_base→x_url) so extension never requires self-reference. - Avoid mutual references between variables; factor shared pieces into a third, independent variable.
- Use distinct
loop_varnames in nested loops so an inner loop cannot read and rewrite the outer name. - When overriding across
group_vars/host_varslayers, override with a literal or a different base var, not the same name referencing itself.
Quick Command Reference
# Find every definition of a variable across precedence layers
grep -rn "<var>" inventory/ group_vars/ host_vars/ roles/*/defaults/ roles/*/vars/
# Resolve (or fail) a single variable with detail
ansible <host> -i inv -m debug -a "var=<var>" -vvv
# Dump a host's fully merged variables to inspect the graph
ansible-inventory -i inventory --host <host>
# Syntax-check before running
ansible-playbook -i inventory site.yml --syntax-check
Conclusion
“recursive loop detected in template string” means a variable’s definition ultimately depends on itself, so Jinja2 has no value to settle on. The fix is always structural: separate a base variable from the derived one, rename instead of redefining a name in terms of itself, and factor out shared pieces so no cycle exists. Grep every precedence layer for the named variable, break the self-reference, and templating resolves cleanly.
More Ansible prompts & error guides
Every Ansible AI prompt and troubleshooting guide, in one place.
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.