

Last updated: April 2026
Playbooks that run every task every time aren't really automation. They're scripts. Real playbooks make decisions: only restart a service when its config changed, only install a package on Debian hosts, only send an alert when a prior task failed. That decision-making comes from Ansible conditionals.
This guide covers the when statement, registered variables, multiple conditions, loops with conditionals, if-else patterns, and Jinja2 template conditionals, with working code examples verified against ansible-core 2.20.4 (March 2026).
At a glance Ansible conditionals use the
whenkeyword to control whether a task runs, based on facts, variables, or prior task results. Current stable release: ansible-core 2.20.4 (March 2026). Conditional expressions are Jinja2 (no{{ }}wrapper needed insidewhen). env zero adds enforcement above the playbook layer: approval gates and policy checks before anyansible-playbookexecution reaches production.
In this guide:
- The
whenstatement: basic syntax - Multiple conditions
- Conditionals with Ansible facts
- Registered variables and task-based conditionals
- Conditionals in loops
- if-else logic in Ansible
- Conditionals in roles and Jinja2 templates
- Ansible conditionals and env zero
- Try it with env zero
- References
- Frequently asked questions
Related reading: Ansible playbooks: syntax, examples, and best practices. If playbook structure is new to you, start there. This guide assumes you're comfortable writing and running basic playbooks before layering in conditional logic.
The when statement: basic syntax
The when keyword is how Ansible decides whether to run a task. It accepts a Jinja2 expression. If the expression is true, the task runs. If false or undefined, Ansible skips it and continues.
One syntax detail worth getting right upfront: inside a when expression, write the variable name directly without {{ }}:
- name: Install Apache on Debian systems
ansible.builtin.apt:
name: apache2
state: present
when: ansible_os_family == "Debian"
The {{ }} notation is valid inside when but redundant; Jinja2 evaluation is already implied. Writing when: "{{ ansible_os_family == 'Debian' }}" works, but the unquoted form is cleaner and what the Ansible docs recommend.
Applying when to a block of tasks
when on a single task means duplicating the condition for every related task. A block groups tasks so one when applies to all of them:
- name: Configure Debian web server
when: ansible_os_family == "Debian"
block:
- name: Install Apache
ansible.builtin.apt:
name: apache2
state: present
- name: Start and enable Apache
ansible.builtin.service:
name: apache2
state: started
enabled: true
- name: Deploy Apache config
ansible.builtin.template:
src: apache_debian.conf.j2
dest: /etc/apache2/apache2.conf
mode: '0644'
notify: Reload Apache
If ansible_os_family is not "Debian", all three tasks are skipped. This is cleaner than repeating when: ansible_os_family == "Debian" three times.
Related reading: Mastering Ansible variables. Variable precedence and scoping directly affect what
whenexpressions evaluate, and understanding this prevents the most common source of unexpected conditional behavior.
Multiple conditions
and: require all conditions
A YAML list under when is shorthand for and. Every item must be true for the task to run:
- name: Install Python 3.11 on Debian 12
ansible.builtin.apt:
name: python3.11
state: present
when:
- ansible_os_family == "Debian"
- ansible_distribution_major_version == "12"
or: require any condition
For or, write the expression inline rather than using a list:
- name: Install nginx on Ubuntu or Debian
ansible.builtin.apt:
name: nginx
state: present
when: ansible_distribution == "Ubuntu" or ansible_distribution == "Debian"
Environment-specific tasks
A common pattern in team environments is gating tasks behind a deployment environment variable:
vars:
deployment_env: "production"
tasks:
- name: Run database migration (production only)
ansible.builtin.command:
cmd: /opt/app/bin/migrate
when: deployment_env == "production"
- name: Load test fixtures (staging only)
ansible.builtin.command:
cmd: /opt/app/bin/load_fixtures
when: deployment_env == "staging"
Conditional debug output
Use ansible.builtin.debug with when to print diagnostic output only during verbose runs:
- name: Show database connection string in debug mode
ansible.builtin.debug:
msg: "Connecting to {{ db_host }}:{{ db_port }}"
when: debug_mode | bool
Grouping complex conditions
Use the YAML block scalar > to write multi-line conditions when mixing and and or:
- name: Apply security hardening on production Linux hosts
ansible.builtin.command:
cmd: /opt/scripts/harden.sh
when: >
(deployment_env == "production") and
(ansible_os_family == "RedHat" or ansible_os_family == "Debian")
The > folds the multi-line string into a single Jinja2 expression. Parentheses make the precedence explicit: and has higher precedence than or in Jinja2, same as Python.
Conditionals with Ansible facts
Using ansible_facts
Ansible gathers facts about each managed node at the start of every play. Both ansible_os_family and ansible_facts['os_family'] reference the same value; the ansible_facts['key'] form is preferred in newer code because it makes the fact namespace explicit:
- name: Install Apache on Debian-based systems
ansible.builtin.apt:
name: apache2
state: present
when: ansible_facts['os_family'] == "Debian"
Common facts for conditionals
| Fact | Example value | Typical use |
|---|---|---|
ansible_facts['os_family'] |
"Debian", "RedHat" |
Branch by OS family |
ansible_facts['distribution'] |
"Ubuntu", "CentOS" |
Branch by exact distro |
ansible_facts['distribution_major_version'] |
"22", "9" |
Branch by OS version |
ansible_facts['architecture'] |
"x86_64", "aarch64" |
Branch by CPU arch |
ansible_facts['default_ipv4']['address'] |
"10.0.1.10" |
IP-based logic |
Full fact reference: Ansible facts documentation.
OS-specific package installation with a loop
- name: Install web server packages (Ubuntu only)
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- apache2
- libapache2-mod-wsgi-py3
when: ansible_facts['distribution'] == "Ubuntu"
Selecting config files by OS family
Rather than using when, you can embed the fact directly in the src path:
- name: Deploy OS-specific nginx config
ansible.builtin.template:
src: "nginx_{{ ansible_facts['os_family'] | lower }}.conf.j2"
dest: /etc/nginx/nginx.conf
mode: '0644'
notify: Reload nginx
This produces nginx_debian.conf.j2 or nginx_redhat.conf.j2 depending on the host. Fewer tasks, same result.
Custom facts
Custom facts let you define application-specific data Ansible gathers alongside system facts. Store them as INI or JSON files in /etc/ansible/facts.d/ on the managed node:
# /etc/ansible/facts.d/app.fact (place this on the managed node)
[app]
version = 2.4.1
environment = production
Access them via ansible_local in playbook conditions:
- name: Refresh local facts
ansible.builtin.setup:
filter: ansible_local
- name: Run migration on production nodes only
ansible.builtin.command:
cmd: /opt/app/bin/migrate
when: ansible_local['app']['app']['environment'] == "production"
The double app path is correct: the first is the filename without .fact, the second is the INI section name. JSON-format fact files avoid this by using flat keys.
Registered variables and task-based conditionals
The register keyword stores a task's output as a variable. Later tasks can branch on it. This is the fundamental pattern for building conditional chains: one task discovers state, the next acts on it.
Basic register pattern: check before act
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/myapp/config.yml
register: config_file
- name: Create default config if missing
ansible.builtin.template:
src: config.yml.j2
dest: /etc/myapp/config.yml
mode: '0640'
owner: appuser
group: appgroup
when: not config_file.stat.exists
The stat module returns a dictionary. stat.exists is true if the path was found. The pattern (check existence, act conditionally) is one of the most common in production playbooks.
Branching on HTTP response status
- name: Check API health endpoint
ansible.builtin.uri:
url: "http://{{ api_host }}/health"
status_code: 200
register: health_check
failed_when: false
- name: Alert on degraded API
ansible.builtin.debug:
msg: "API health check failed, status code: {{ health_check.status }}"
when: health_check.status != 200
failed_when: false prevents the task from stopping the play on a non-200 response, so the second task can inspect and act on the result.
Handling task failures
- name: Restart web service
ansible.builtin.service:
name: myapp
state: restarted
register: restart_result
failed_when: false
- name: Alert on restart failure
ansible.builtin.debug:
msg: "Service restart failed on {{ inventory_hostname }}, check logs"
when: restart_result.failed
Reusing a registered result across multiple tasks
- name: Get current app version
ansible.builtin.command:
cmd: /opt/app/bin/version --short
register: app_version
changed_when: false
- name: Run migration for versions below 3.0
ansible.builtin.command:
cmd: /opt/app/bin/migrate_v3
when: app_version.stdout is version('3.0', '<')
- name: Log version in debug mode
ansible.builtin.debug:
msg: "Running app version: {{ app_version.stdout }}"
when: debug_mode | bool
is version() is a Jinja2 test built into Ansible for comparing version strings. It handles semantic versioning correctly, with no custom string parsing needed.
Related reading: The ultimate Ansible tutorial. Registered variables and handlers work closely together: tasks notify handlers when they change, handlers check registered results to decide how to respond.
Conditionals in loops
Filtering loop items with when
When you add when to a task with a loop, the condition evaluates for each item individually. Items that don't match are skipped:
- name: Install packages (Ubuntu only)
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- curl
- wget
- htop
when: ansible_facts['distribution'] == "Ubuntu"
Filtering by item attribute
- name: Create bash-shell users only
ansible.builtin.user:
name: "{{ item.name }}"
shell: "{{ item.shell }}"
state: present
loop:
- { name: alice, shell: /bin/bash }
- { name: bob, shell: /bin/zsh }
- { name: carol, shell: /bin/bash }
when: item.shell == "/bin/bash"
alice and carol are created. bob is skipped. The condition references item (the current loop element), which can be a string, dict, or any YAML value.
Loop with registered results
Register a loop result and act on individual items in a follow-up task:
- name: Check which config files exist
ansible.builtin.stat:
path: "{{ item }}"
register: file_checks
loop:
- /etc/myapp/config.yml
- /etc/myapp/secrets.yml
- /etc/myapp/overrides.yml
- name: Report missing config files
ansible.builtin.debug:
msg: "Missing required file: {{ item.item }}"
loop: "{{ file_checks.results }}"
when: not item.stat.exists
file_checks.results is a list with one entry per iteration. Each entry has item (the original loop value) and the full task return dict (stat in this case). Looping over results in the follow-up task and conditioning on item.stat.exists is the standard pattern for bulk checks.
For the above, if /etc/myapp/secrets.yml and /etc/myapp/overrides.yml are missing, output looks like:
TASK [Report missing config files] **************************
ok: [webserver1] => (item={...}) => {
"msg": "Missing required file: /etc/myapp/secrets.yml"
}
ok: [webserver1] => (item={...}) => {
"msg": "Missing required file: /etc/myapp/overrides.yml"
}
if-else logic in Ansible
Ansible has no native if/else. The equivalent is two tasks with complementary when conditions:
- name: Install Apache on Debian/Ubuntu
ansible.builtin.apt:
name: apache2
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Install Apache (httpd) on RHEL/CentOS
ansible.builtin.dnf:
name: httpd
state: present
when: ansible_facts['os_family'] == "RedHat"
Simulating else-if chains
Add more tasks with progressively more specific conditions for multi-branch logic:
- name: Configure for Ubuntu 22.04
ansible.builtin.template:
src: config_ubuntu22.j2
dest: /etc/myapp/config
mode: '0644'
when:
- ansible_facts['distribution'] == "Ubuntu"
- ansible_facts['distribution_major_version'] == "22"
- name: Configure for Ubuntu 20.04
ansible.builtin.template:
src: config_ubuntu20.j2
dest: /etc/myapp/config
mode: '0644'
when:
- ansible_facts['distribution'] == "Ubuntu"
- ansible_facts['distribution_major_version'] == "20"
- name: Configure for all other distributions
ansible.builtin.template:
src: config_default.j2
dest: /etc/myapp/config
mode: '0644'
when: ansible_facts['distribution'] != "Ubuntu"
Default fallback
For a catch-all on unsupported systems, use not (condition) or a negated match:
- name: Apply tuned profile on RHEL systems
ansible.builtin.command:
cmd: tuned-adm profile throughput-performance
when: ansible_facts['os_family'] == "RedHat"
- name: Apply cpupower profile on non-RHEL systems
ansible.builtin.command:
cmd: cpupower frequency-set -g performance
when: ansible_facts['os_family'] != "RedHat"
Conditional file permissions
- name: Strict permissions on production secrets file
ansible.builtin.file:
path: /etc/myapp/secrets.yml
mode: '0600'
owner: root
group: root
when: deployment_env == "production"
- name: Relaxed permissions on staging secrets file
ansible.builtin.file:
path: /etc/myapp/secrets.yml
mode: '0640'
owner: appuser
group: appgroup
when: deployment_env != "production"
If a task requires three or more nested conditions to read clearly, that's a signal to refactor. Compute a derived value with ansible.builtin.set_fact and condition on that single variable instead of nesting logic inline.
Conditionals in roles and Jinja2 templates
Conditional role inclusion
Use ansible.builtin.include_role with when to apply roles only to matching hosts:
- hosts: all
tasks:
- name: Apply web server role on Debian systems
ansible.builtin.include_role:
name: webserver
when: ansible_facts['os_family'] == "Debian"
- name: Apply monitoring role on all systems
ansible.builtin.include_role:
name: monitoring
when at the include_role level means the role's tasks only run when the condition is met; the role isn't loaded at all on non-matching hosts.
Jinja2 conditionals inside templates
Ansible templates (.j2 files) support full Jinja2 {% if %} syntax. This is useful for config files that vary by environment or OS without needing separate template files for each variant:
{# /templates/nginx.conf.j2 #}
worker_processes {{ ansible_facts['processor_vcpus'] }};
{% if ansible_facts['os_family'] == "Debian" %}
pid /run/nginx.pid;
{% else %}
pid /var/run/nginx.pid;
{% endif %}
server {
listen {{ nginx_port | default(80) }};
server_name {{ server_name }};
{% if ssl_enabled | bool %}
listen {{ nginx_port | default(443) }} ssl;
ssl_certificate /etc/ssl/certs/{{ server_name }}.crt;
ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
{% endif %}
}
Deploy with ansible.builtin.template:
- name: Deploy nginx config from template
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
validate: nginx -t -c %s
notify: Reload nginx
Deploying OS-specific templates conditionally
When two templates are too different to merge into one, deploy them via separate tasks:
- name: Deploy Debian nginx config
ansible.builtin.template:
src: nginx_debian.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
when: ansible_facts['os_family'] == "Debian"
notify: Reload nginx
- name: Deploy RedHat nginx config
ansible.builtin.template:
src: nginx_redhat.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
when: ansible_facts['os_family'] == "RedHat"
notify: Reload nginx
Both tasks notify the same handler (Reload nginx), which runs once at the end of the play regardless of which task changed the file.
Ansible conditionals and env zero
Writing conditionals handles the "what runs where" question inside a playbook. The harder problem at team scale is the "who can run this, on which environment, after what approval" question. Those controls live outside the playbook.
env zero adds a governance layer over existing playbooks without changing any YAML. Before any ansible-playbook execution, env zero checks: does this user have permission to run against this environment? Is a reviewer required before production changes? Have all policy checks passed?
Environment-gated tasks like when: deployment_env == "production" control what happens inside a run. env zero controls whether the run happens at all. Role-Based Access Control (RBAC), approval workflows, and drift detection operate above the playbook, not inside it. The two layers complement each other: conditionals provide per-task precision, env zero provides organizational guardrails.
Teams that have tried to enforce governance through playbook conditionals alone find the model breaks when someone changes a variable at runtime or runs the playbook outside the standard workflow. env zero closes that gap without requiring a rewrite of existing automation.
See env zero's IaC automation overview for how this fits into a full infrastructure governance workflow.
What's next
- Protecting secrets with Ansible Vault: encrypt sensitive variables used in conditional logic so passwords and API keys never appear in plaintext
- Mastering Ansible variables: variable precedence and scoping; understanding this prevents the most common source of unexpected conditional behavior
- Ansible playbooks guide: handlers, tags, and the execution model that conditionals run inside
Try it with env zero
Ansible conditionals handle the task-level logic. env zero handles who can run those tasks, on which environments, and with what level of review. Add RBAC, approval gates, and audit logs to your existing playbooks without changing a line of YAML.
Start a free trial or book a demo.
References
- Ansible conditionals documentation: official reference for
when,register, and Jinja2 expressions - Ansible facts and variables: complete list of gathered facts and
ansible_localusage - Jinja2 tests in Ansible:
is version(),is defined,is match(), and more - ansible.builtin.stat module: return values used in register patterns
- ansible-core 2.20.4 release notes: current stable release (March 2026)
- Mastering Ansible variables: variable precedence and scoping
- Ansible playbooks guide: playbook structure, handlers, and the execution model
Frequently asked questions
What is the when statement in Ansible?
The when statement is a task-level keyword that controls whether a task runs. It accepts a Jinja2 expression: true means run, false or undefined means skip. No {{ }} wrapper needed:
- name: Install Apache on Debian
ansible.builtin.apt:
name: apache2
state: present
when: ansible_facts['os_family'] == "Debian"
Does Ansible have if-else?
No. Ansible has no native if/else construct. Use two tasks with complementary when conditions:
- name: Install on Debian/Ubuntu
ansible.builtin.apt:
name: nginx
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Install on RHEL/CentOS
ansible.builtin.dnf:
name: nginx
state: present
when: ansible_facts['os_family'] == "RedHat"
How does register work with conditionals?
register saves a task's output to a variable. Later tasks can reference it in when:
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/myapp/config.yml
register: config_check
- name: Create config if missing
ansible.builtin.template:
src: config.yml.j2
dest: /etc/myapp/config.yml
when: not config_check.stat.exists
How do I combine multiple conditions?
A YAML list under when is an implicit and. Use inline or for alternatives:
# Both must be true (implicit "and")
when:
- ansible_facts['os_family'] == "Debian"
- ansible_facts['distribution_major_version'] == "12"
# Either is sufficient
when: ansible_facts['distribution'] == "Ubuntu" or ansible_facts['distribution'] == "Debian"
# Negation
when: not ansible_check_mode
Can I use custom facts in conditionals?
Yes. Store a fact file in /etc/ansible/facts.d/ on the managed node:
# /etc/ansible/facts.d/app.fact
[app]
environment = production
Access it via ansible_local after refreshing facts:
- name: Refresh local facts
ansible.builtin.setup:
filter: ansible_local
- name: Run migration on production only
ansible.builtin.command:
cmd: /opt/app/bin/migrate
when: ansible_local['app']['app']['environment'] == "production"
What is the difference between when and Jinja2 {% if %}?
when is a task-level Ansible keyword that controls whether an entire task runs. Jinja2 {% if %} appears inside .j2 template files and controls what content gets rendered. They operate at different layers: when in playbooks, {% if %} in templates. Both accept the same Jinja2 expression syntax.
Can I use when with block?
Yes. A when on a block applies to every task inside it, which avoids repeating the same condition across multiple tasks:
- when: ansible_facts['os_family'] == "Debian"
block:
- name: Install Apache
ansible.builtin.apt:
name: apache2
state: present
- name: Start Apache
ansible.builtin.service:
name: apache2
state: started
enabled: true
Why does a false when condition show as "skipped" rather than failing?
Skipped is intentional. A false condition means the task didn't apply, which is a normal outcome. If you want a false condition to stop the play, use ansible.builtin.assert or ansible.builtin.fail with the inverse condition instead.

.webp)

![Using Open Policy Agent (OPA) with Terraform: Tutorial and Examples [2026]](https://cdn.prod.website-files.com/63eb9bf7fa9e2724829607c1/69d6a3bde2ffe415812d9782_post_th.png)