Tests and Conditionals
Tests answer yes/no questions about a value with the is keyword. {% if tls_cert is defined %} checks whether a variable exists; {% if port is number %} checks its type. Combined with {% if %} and {% for %}, tests let one template render different output per host — an nginx config that includes a TLS block only when a cert is configured, or an upstream list with one line per web host in the inventory.
This is where a template stops being fill-in-the-blanks and starts adapting to what each machine actually has. The same larkspur.conf.j2 can serve an HTTP-only staging box and an HTTPS prod box, and grow its load-balancer block automatically as you add web hosts. The skill is writing conditionals that render cleanly on every host in the fleet, including the one missing the optional variable or the freshly added one without facts yet.
is defined and is not defined
is defined is the most-used test, guarding optional config. {% if ssl_certificate is defined %} renders the TLS directives only when the variable exists, so the same larkspur.conf.j2 serves an HTTP-only staging box and an HTTPS prod box from one file. The mirror image, is not defined, guards the fallback branch. Together they let a template carry optional sections without crashing on the host that lacks them.
The mistake people make is reaching for a bare truthiness check — {% if ssl_certificate %} — when they mean "does this exist." On a host where the variable is simply unset, the bare form raises an undefined error, or under a permissive policy renders nothing in a way that is hard to debug. is defined is the existence check you actually want, and it says exactly that.
The Built-in Tests
Beyond existence, Jinja2 ships a useful set: is none, is number, is string, is iterable, is in [...], and is match('regex'). The membership test earns its keep for readability — {% if env is in ['staging', 'prod'] %} reads cleaner than a chain of or comparisons and fails more obviously when the value is something unexpected like "production" instead of "prod".
The trap with membership is using == against a list when you meant is in. {% if env == ['staging', 'prod'] %} compares the value to the list itself — it asks "is env equal to this two-element list," which is never true for a string. is in [...] is the membership test. The two look similar and mean entirely different things, and the wrong one silently renders the wrong branch.
Rendering Repeated Blocks with for
A {% for %} loop emits one block per item, which is how a config tracks the fleet automatically. Looping over groups['web'] to emit one server web1.larkspur.io:8000; line per host builds the gunicorn upstream block without you maintaining a list by hand. Add a web host to inventory and the load-balancer config grows on the next run, with no template edit. The inventory becomes the single source of truth for the config.
{% for host in groups['web'] %} server {{ hostvars[host]['ansible_host'] }}:8000; # each looped host's own IP {% endfor %}
The footgun inside the loop is referencing a bare fact name. Writing ansible_host directly renders the current play host's value on every line, because that is the host the template is rendering for. To read each looped host's data you go through hostvars[host]['ansible_host'], indexing the global hostvars by the loop variable. Bare fact names in a loop are almost always a bug.
Combining Tests with Conditionals
Defensive conditionals keep a fleet-wide template from being only as reliable as its flakiest host. {% if hostvars[host]['ansible_default_ipv4'] is defined %} inside a loop skips a host that has not gathered facts, so one missing fact does not crash the whole render. Without the guard, a single host added to the group but not yet reachable takes down the config generation for every other host in the loop.
The other silent failure is a loop over an empty group. A {% for %} over a group that exists but has no members renders nothing — no error, just an empty upstream block, which nginx then rejects at reload time. A group that is not in the inventory at all fails louder, not silently: groups['web'] on a missing key raises an undefined error rather than rendering empty. Guard the loop with is defined (or groups.get('web', [])) and a non-empty check so the template either produces a valid block or fails clearly, rather than shipping an empty one that breaks the service later.
loop Variables and pg_hba.conf
Inside a {% for %}, Jinja2 exposes loop.index, loop.first, and loop.last — the position metadata you need to handle separators and boundaries. The canonical Larkspur use is rendering pg_hba.conf as one host line per allowed web subnet, looping the subnet list straight into PostgreSQL's access rules. The config tracks the fleet, and adding a web subnet is an inventory change, not a hand-edit.
loop.last earns its keep on anything with separators. A list rendered into a JSON array or a comma-joined directive needs the comma on every element except the last, and forgetting to special-case it leaves a trailing comma the parser rejects. {% if not loop.last %},{% endif %} handles it cleanly, keeping the rendered file syntactically valid on the boundary element where naive loops break.
- Writing
{% if ssl_certificate %}instead of{% if ssl_certificate is defined %}— the bare form raises an undefined error (or renders nothing under a permissive policy) when the variable is unset, whileis definedis the existence check you meant. - Using
==against a list ({% if env == ['staging', 'prod'] %}) when you meant membership — that compares the value to the list itself;is in [...]is the membership test. - Looping over
groups['web']to build an upstream block but referencingansible_hostdirectly instead ofhostvars[host]['ansible_host'], so every line renders the current play host's value rather than each looped host's. - Forgetting that a
{% for %}over an empty group renders nothing silently, leaving an empty upstream block that nginx rejects — guard withis definedand a non-empty check (a group missing from the inventory entirely raises an undefined error instead). - Leaving a trailing comma or newline because the loop did not special-case
loop.last, producing a config the parser rejects on the final element.
- Guard every optional directive with
is defined(ordefault) so a template renders cleanly whether or not the optional variable is set, instead of crashing on the host that lacks it. - Build repeated config blocks — upstream servers,
pg_hba.conflines — by looping over inventory groups, so the config tracks the fleet automatically and adding a host needs no template change. - Reference looped hosts through
hostvars[host][...], never the bare fact name, so each iteration reads that host's data rather than the play target's. - Use
loop.lastandloop.firstto handle separators and trailing punctuation, keeping the rendered file syntactically valid on the boundary elements. - Prefer
is in ['staging', 'prod']over a chain oforcomparisons for membership, so the condition reads clearly and fails obviously on an unexpected value.
can() / try() cover the same loop-and-guard ground
ERB <% if %> / <% each %>, the Chef and Puppet equivalent
Helm Go-template range and if play the same role
Knowledge Check
Why use {% if ssl_certificate is defined %} rather than {% if ssl_certificate %} to guard an optional TLS block?
- The bare form raises an undefined error on a host where the variable is unset;
is definedis the existence check you actually mean - The bare form is measurably slower because it has to read and evaluate the variable's full contents first before deciding the branch
- The bare truthiness form only works on a plain string, not on a certificate file-path value or any other non-string type
- There is no real difference between the two;
is definedis simply a more explicit and readable style choice
What does {% if env == ['staging', 'prod'] %} actually test?
- Whether
envequals the two-element list itself — never true for a string; you wantedis in [...]for membership - Whether
envis a member of the list — it is the correct membership test for checking the value against both options - Whether the list literal on the right-hand side contains exactly two elements and no more
- Whether
envmatches either of the listed values, with==expanding into an implicitoracross them
In a {% for host in groups['web'] %} loop, why reference hostvars[host]['ansible_host'] instead of bare ansible_host?
- Bare
ansible_hostrenders the current play host's value on every line;hostvars[host]reads each looped host's own data - Bare
ansible_hostis left completely undefined inside anyforloop body, so the line renders an empty value hostvarsis required only when the inventory group holds more than one host; with a single host the bare name is fine- The two are fully equivalent here, and
hostvars[host]is just the more verbose and explicit way to write the same thing
What does special-casing loop.last prevent when rendering a comma-separated config block?
- A trailing comma after the final element, which the parser rejects
- The loop running one extra empty iteration past the end of the backing list
- Facts being gathered redundantly on the last host the loop visits
- The first element being skipped entirely when the list holds only one item
You got correct