Chapter 5: Variables & Facts
Topic 26

Defining Variables

ConceptVariables

A variable in Ansible is a named value you reference in a playbook, template, or conditional, and the value can arrive from a dozen different places — a vars: block in the play, a vars_files, a role's defaults/, group_vars/host_vars, or -e on the command line. The same {{ name }} syntax pulls whichever value won, and the type can be a scalar, a list, or a dictionary.

Getting variables right starts with knowing where a value can come from and what shape it has, before you ever worry about which source beats which. This topic draws the map of sources and types; the next one ranks them into a precedence order. Larkspur's web tier feeds its nginx port, its package set, and its environment block from exactly these mechanisms, so the shapes here are the ones you template for the rest of the chapter.

Where Variables Come From

A value can be defined in the play itself — a vars: block or a vars_files: list pulled in at play scope — or it can ride along with the inventory through group_vars/ and host_vars/ files keyed by group and host name. A role carries two more sources: defaults/main.yml holds the overridable baseline a consumer is meant to tune, and vars/main.yml holds near-fixed internal values the role relies on. And -e/--extra-vars injects a value straight from the command line.

Each source has a different scope and a different precedence. A group_vars/web.yml value attaches to every host in the web group; a role default is visible wherever that role runs; an -e value is visible everywhere for that one invocation. This topic establishes the map of where a value can live; Topic 27 takes the same map and ranks it, because once you have more than one definition of the same name, scope alone no longer tells you which value the template sees.

Scalars, Lists, and Dictionaries

A variable holds one of three shapes. A scalar is a single value — nginx_port: 443, a number or a string. A list is ordered — base_packages: [git, curl, ufw] — and you iterate it. A dictionary is keyed — app_env: {LOG_LEVEL: info, WORKERS: 4} — and you index it by name. The shape is not cosmetic: it decides which Jinja2 construct reads the value, and feeding the wrong shape to a module is a common parse-time or run-time failure.

Larkspur uses all three. Its nginx settings are a dict, its base package set a list, and its listening port a scalar. The package list gets iterated in a loop or joined into a string; the env dict gets indexed key by key into the templated app.env; the port drops straight into the nginx config. Choosing the shape deliberately — and matching it to how the value is consumed — is half of writing variables that behave.

The three value shapes you reference with {{ }}
Scalar
A single value — nginx_port: 443. Dropped straight in with {{ larkspur_web_nginx_port }}.
List
Ordered values — [git, curl, ufw]. Iterated in a loop or joined with {{ packages | join(', ') }}.
Dictionary
Keyed values — {LOG_LEVEL: info}. Indexed by name with {{ app_env.WORKERS }}.
group_vars/web.yml — a scalar, a list, and a dict, the three shapes Larkspur's web tier feeds
# scalar — one value
larkspur_web_nginx_port: 443

# list — ordered, iterated or joined
larkspur_web_base_packages:
  - nginx
  - git
  - ufw

# dict — keyed, indexed by name
larkspur_web_app_env:
  LOG_LEVEL: info
  WORKERS: 4
  BIND: 127.0.0.1:8000

Keeping this structured config in a group_vars file under git — referenced by name from tasks and templates rather than inlined as literals — is what lets one edit change every web host at once, with a reviewed history behind it.

Referencing with Double Braces

You read a variable with Jinja2 expansion: {{ larkspur_web_nginx_port }} drops the scalar into a template, {{ larkspur_web_app_env.WORKERS }} indexes the dict by key, and {{ larkspur_web_base_packages | join(', ') }} runs the list through a filter to produce a comma-separated string. The same {{ }} works in a template file, in a task argument, and inside a when: conditional — one referencing syntax across every place a value is consumed.

There is one YAML trap that bites everyone once. A value that starts with {{ must be quoted, because bare key: {{ x }} looks like a YAML inline dictionary to the parser, and the file fails to load before any host is touched. Write port: "{{ larkspur_web_nginx_port }}" with quotes and YAML reads it as a string that Jinja2 then renders. Mid-string interpolation like url: "https://{{ host }}" already needs quotes for the literal, so the rule only surprises you when {{ is the very first character.

Variable Scope

Every source has a lifetime, and confusing lifetimes produces the "why is this variable undefined three tasks later" puzzle. A play var is visible to every task and role inside that play, but does not bleed into a later play in the same run. A set_fact value lives for the rest of the run on the host that set it, surviving across roles and later tasks. A role default is visible wherever that role runs and nowhere else.

The practical consequence is to match the source to how long you need the value. Something every play in a run reads belongs in group_vars or host_vars, attached to inventory and therefore present for the whole run. Something one play computes and the next play needs is not a plain play var — that scope ends with the play. Knowing the lifetime of each source up front saves the debugging session where a value that was clearly set vanishes a few tasks on.

Naming Rules and Valid Identifiers

Variable names are letters, digits, and underscores only, must start with a letter or underscore, and cannot collide with Python keywords or with Ansible's reserved ansible_-prefixed namespace. So nginx_port and _internal are valid, while nginx-port (a hyphen reads as minus) and 2nd_host (a leading digit) are not. An invalid name fails at parse, when the file loads — not deep into a run when the value is finally referenced — which at least makes the error loud and early.

This is why every shape in the Larkspur examples uses underscores and a role prefix: larkspur_web_nginx_port, not nginx-port or web.port. The hyphen habit carried over from shell and YAML keys is the most common way to write a name Ansible rejects, and the prefix discipline that Topic 32 covers depends on names being valid identifiers in the first place.

Common Mistakes
  • Writing port: {{ web_port }} unquoted in YAML — a value starting with {{ is read as a YAML inline dict and the file fails to parse before any host is touched; it must be port: "{{ web_port }}".
  • Defining a value the role must be able to override in the role's vars/main.yml instead of defaults/main.ymlvars/ sits high in precedence and a group_vars file can't override it, so the "configurable" setting silently isn't.
  • Hyphenating a variable name (db-password) or starting it with a digit (2nd_host) — these are invalid identifiers and the play fails at load, not at use.
  • Expecting a set_fact from one play to be visible in a later play of the same run by default, or expecting a play vars: to bleed into play two — play scope ends with the play, and fact scope is per-host for the run.
  • Storing a list where a scalar is expected and feeding it to a module — state: "{{ states }}" with states a list passes the literal list to the module, which errors on an unexpected value.
Best Practices
  • Put a role's overridable knobs in defaults/main.yml and its internal constants in vars/main.yml, so consumers tune the first tier without fighting precedence.
  • Quote any value that begins with {{ so YAML parses it as a string rather than a malformed inline dict.
  • Type variables deliberately — a list for base_packages, a dict for app_env, a scalar for nginx_port — and template each with the matching construct: join for the list, indexing for the dict, direct expansion for the scalar.
  • Keep structured config — the nginx dict, the package list — in group_vars files under git and reference it by name, rather than inlining literals across tasks.
  • Name every variable with letters, digits, and underscores only, starting with a letter or underscore, so it loads as a valid identifier and survives the prefixing discipline that follows.
Comparable tools Puppet class parameters and Hiera data Chef node attributes with default / override levels Salt pillars Terraform input variables in .tfvars

Knowledge Check

Why must port: {{ web_port }} be quoted as port: "{{ web_port }}" in a YAML file?

  • A value starting with {{ is read by the YAML parser as an inline dict, so the whole file fails to load before any host runs
  • An unquoted Jinja2 expression is evaluated only once at define time rather than freshly at the moment of every later use
  • Ansible only renders {{ }} inside double-quoted scalars and treats the braces as literal text everywhere else
  • The quotes coerce the rendered number into a string, which every module argument silently requires

What is the scope difference between a play vars: value and a set_fact value?

  • A play var is visible only within that play, while a set_fact persists for the rest of the run on the host that set it
  • A play var survives the entire run while a set_fact value lasts only for the single current task that set it on the host
  • Both are global and shared across every play and every host for the whole duration of the run
  • A set_fact is visible only inside the role that set it, while a play var is visible everywhere

A role setting must be overridable from a group_vars file. Where should it be defined?

  • In the role's defaults/main.yml, the overridable baseline that group and host vars outrank
  • In the role's vars/main.yml, which sits high in precedence so it can't be overridden by inventory
  • In a set_fact at the top of the role so it is computed fresh each run
  • In -e on the command line so the value is always pinned

Which of these is an invalid Ansible variable name that fails at parse?

  • db-password — a hyphen is not allowed in an identifier, so it fails when the file loads
  • db_password — underscores are the conventional separator and load without complaint
  • _internal_state — a leading underscore is permitted and parses cleanly
  • larkspur_web_nginx_port — letters, digits, and underscores are all legal identifier characters

You got correct