Chapter 5: Variables & Facts
Topic 32

Variable Hygiene

ConceptWorkflow

A variable named port is a time bomb: two roles both define it, the second clobbers the first, and a deploy goes sideways because nginx and PostgreSQL fought over one name. As a project grows past a handful of roles, the discipline that keeps it sane is namespacing — prefix every role's variables with the role name — plus an understanding of the precedence and late-binding traps that make a variable hold a value you didn't expect.

This topic is the practical hygiene that turns Topics 26 through 31 into something maintainable. The sources, the precedence, the facts, and the magic variables are all mechanisms; what keeps them from collapsing into a debugging nightmare on a real Larkspur repo with a web role and a database role is a small set of habits applied without exception.

One Global Namespace

Ansible variables share a single flat namespace per host, so a web role defining port: 443 and a database role defining port: 5432 are the same variable. There is no per-role scope to keep them apart — the one resolved later or higher in precedence wins, and the loser's role silently misbehaves with no warning. nginx ends up trying to bind 5432, or PostgreSQL gets told it lives on 443, and nothing in the run flags the collision.

This is the structural fact that drives every other practice in the topic. Unlike a language where each module has its own scope, Ansible flattens all variables into one space per host, so two unrelated roles can step on each other purely by choosing the same obvious name. The fix is not a setting; it is a naming discipline you impose yourself, because the tool will not impose it for you.

Prefix Role Variables With the Role Name

Prefix every role variable with the role's name — larkspur_web_nginx_port, larkspur_web_app_dir, postgres_port — and collisions become structurally impossible. larkspur_web_nginx_port and postgres_port cannot clash, because no two roles share a prefix. The prefix also makes a variable's owner obvious at a glance: anyone reading a template knows instantly which role a value belongs to, without grepping for where it's defined.

This is the single highest-value habit in a multi-role repo. It costs a few extra characters per name and buys immunity from the entire category of silent collision bugs. The Larkspur examples throughout this chapter use it for exactly this reason — every web-tier variable carries larkspur_web_, so the database role and the web role can each define a "port," a "user," and a "path" without ever touching the same name.

defaults Are Public API, vars Are Private

A role's defaults/ are the knobs consumers override — prefix them, document them, treat them as the role's public API. Its vars/ are internal and high-precedence: consumers can't easily override them, because vars/ outranks group_vars, so anything you put there is effectively fixed from the outside. Keep internal constants in vars/ and keep everything a consumer should tune in defaults/.

Mixing the two is how a "configurable" role turns out not to be. Put an overridable setting in vars/ and tell users they can change it from their inventory, and their group_vars edit is silently ignored because vars/ sits higher in precedence. The boundary is a contract: defaults/ says "override me," vars/ says "internal, hands off," and respecting it is what makes a role actually reusable rather than reusable-in-theory.

Jinja2 Renders at Use Time, Not Define Time

app_url: "https://{{ web_host }}:{{ web_port }}" is not evaluated when it is defined. The {{ }} is stored as a string, and rendered the moment the variable is used, against whatever web_host and web_port resolve to then. So a variable that references other variables can silently change meaning depending on where it is read — the classic "it was right in the role and wrong in the play" bug, where the same app_url renders one value inside the role and another after a play-level override changed web_port.

This late binding is a feature when you want it — a templated variable that follows whatever the inputs currently are — and a trap when you assumed the value was captured at definition. The mental model that saves you is: a {{ }} in a variable value is a recipe, not a result. It is re-cooked every time someone reads the variable, with whatever ingredients are in scope at that read.

hostvars and Cross-Host Late Binding

Referencing hostvars[...] inside a templated variable compounds the trap. The value now depends on two things at once: the other host's facts being present, and when the expression renders. A db_address: "{{ hostvars['db1.larkspur.io']['ansible_facts']['default_ipv4']['address'] }}" read before db1 was gathered throws undefined; read after, it works — same variable, different outcome by timing.

Worse is a circular reference. Define a: "{{ b }}" and b: "{{ a }}", and rendering either one recurses until Ansible aborts with a recursive-loop template error — at render time, not at parse time, so the file loads cleanly and fails only when something reads the chain. Self-referential and circular {{ }} chains are the late-binding trap at its sharpest, and the defense is to keep variable values from referencing other templated variables in long chains.

Discipline Over Cleverness

Flat names, role prefixes, and avoiding chains of variables that reference variables that reference facts keep the resolved value predictable. The clever multi-level {{ }} indirection that looks elegant in the YAML is exactly what makes a value impossible to reason about from the files, because precedence decides which definition wins and late binding decides what it renders to — neither visible by reading the source.

When a value still surprises you, debug: var=<name> at the exact task is the only honest answer. Reading the YAML cannot tell you the resolved value, because precedence plus late binding defeats static reading — you must observe the value at the point it is used. Pair that with ansible-inventory --host <host> for the inventory side, and the two together settle any "why is this variable that value" question that the source files can't. That habit closes out the chapter; Chapter 6 takes these variables and facts into the templating engine itself.

Common Mistakes
  • Two roles both defining a bare port — or user, path, version — in one play; they share one namespace, the later or higher-precedence one wins, and the other role silently configures the wrong value.
  • Putting a consumer-overridable setting in a role's vars/ instead of defaults/, then telling users they can override it — vars/ outranks group_vars, so their override is ignored and the role isn't actually configurable.
  • Assuming app_url: "https://{{ web_host }}" captured web_host's value at definition — it renders at use time, so the same variable yields different URLs depending on what web_host resolves to where it's read.
  • Building a chain of variables that reference variables that reference facts and then debugging by reading the YAML — late binding means the files don't tell you the resolved value; only a debug at the task does.
  • Two variables referencing each other — a: "{{ b }}", b: "{{ a }}" — which makes Ansible recurse and abort with a recursive-loop error at render, not at parse.
Best Practices
  • Prefix every role variable with the role name — larkspur_web_nginx_port, not port — so collisions are structurally impossible and ownership is obvious.
  • Treat role defaults/ as the public, overridable API and role vars/ as private internals, keeping anything consumers should tune in defaults/.
  • Remember that {{ }} renders at use time, not define time, and avoid deep chains of variables referencing variables referencing facts so the resolved value stays predictable.
  • When a value surprises you, settle it with debug: var=<name> at the exact task plus ansible-inventory --host <host>, because precedence and late binding make the source files unreliable as a guide.
  • Keep variable values from referencing other templated variables in long chains, and never let two values reference each other, so no read can trigger a recursive-loop render error.
Comparable tools Puppet namespaces variables by class and module scope Chef scopes attributes under cookbook names Terraform gives each module its own variable namespace by design Ansible one flat per-host namespace — prefixing is a discipline you impose

Knowledge Check

Why do two roles each defining a bare port clobber each other?

  • Ansible variables share one flat namespace per host, so both port definitions are the same variable and the higher-precedence one wins
  • Each role is given its own fully isolated variable scope, but the engine still raises a duplicate-name error and aborts the whole play immediately
  • Roles are always processed in alphabetical order, so the second role's port value is the one quietly discarded
  • The collision is harmless because Ansible automatically suffixes each port with its owning role name

Why does a consumer-overridable role setting belong in defaults/ rather than vars/?

  • vars/ outranks group_vars, so a consumer's override is ignored; defaults/ sits at the floor and can be overridden
  • vars/ is loaded only in check mode, so any value defined there never applies during a real run
  • defaults/ is the private tier and vars/ is the public one, so any consumer-overridable knob clearly belongs in the public vars/
  • There is no precedence difference between the two; the choice is purely a matter of stylistic preference

What does "late binding" mean for a {{ }} expression stored in a variable's value?

  • It is kept as a string and rendered at use time against whatever the referenced variables resolve to then, not captured at definition
  • It is evaluated exactly once at the moment the file loads, and that single rendered result is then frozen in place for the rest of the entire run
  • It binds to the value the referenced variable held back in the previous run, pulled in via the fact cache
  • It is rendered only when the variable is referenced inside a template file, and never from within a task

Why can reading the YAML fail to tell you a templated variable's resolved value, and what gives you the answer?

  • Precedence decides which definition wins and late binding decides what it renders to, neither visible in the source — a debug: var=<name> task shows it
  • The whole YAML file is encrypted at rest by Ansible Vault, so only a deliberate manual decrypt step run beforehand can ever reveal the underlying value
  • Ansible strips out comments and whitespace at load time, so the stored file no longer matches what actually runs
  • Reading the YAML always tells you the value directly; a debug task is only ever useful for registered vars

You got correct