Introduction
changed_when is an Ansible task directive that overrides the default changed status, letting you decide — based on a condition you write — whether a task counts as a change. Paired with failed_when, it lets you control when a task reports changed or failed status. By default, Ansible relies on module return codes — but shell commands, scripts, and some modules don't report status accurately. These directives make your playbooks idempotent and your reporting meaningful.
The Problem
# This always reports "changed" even when nothing changed:
- name: Check if app is running
ansible.builtin.command: systemctl is-active myapp
register: result
# Status: changed ← misleading!
# This fails when the command returns non-zero (expected behavior):
- name: Check if user exists
ansible.builtin.command: id appuser
register: user_check
# If user doesn't exist → rc=1 → task fails ← not what we want
changed_when
Controls when a task reports changed: true.
# ─── Suppress changed for read-only commands ───
- name: Check application version
ansible.builtin.command: myapp --version
register: version_check
changed_when: false # Never reports changed
# ─── Changed only when output indicates a change ───
- name: Update database schema
ansible.builtin.command: alembic upgrade head
register: migration
changed_when: "'Running upgrade' in migration.stdout"
# Only "changed" when a migration actually ran
# ─── Changed based on return code ───
- name: Apply kernel parameters
ansible.builtin.command: sysctl --system
register: sysctl_result
changed_when: sysctl_result.rc == 0 and 'applied' in sysctl_result.stdout
# ─── Multiple conditions ───
- name: Git pull latest
ansible.builtin.command: git pull origin main
args:
chdir: /opt/app
register: git_result
changed_when:
- git_result.rc == 0
- "'Already up to date' not in git_result.stdout"
# ─── With shell commands ───
- name: Create user if not exists
ansible.builtin.shell: |
if ! id appuser &>/dev/null; then
useradd -r appuser
echo "CREATED"
else
echo "EXISTS"
fi
register: user_result
changed_when: "'CREATED' in user_result.stdout"
failed_when
Controls when a task reports failed: true.
# ─── Don't fail on specific return codes ───
- name: Check if service exists
ansible.builtin.command: systemctl status myapp
register: service_check
failed_when: service_check.rc not in [0, 3, 4]
# rc=3 means "not running" (not an error)
# rc=4 means "not found" (also acceptable here)
# ─── Fail on specific output ───
- name: Run database healthcheck
ansible.builtin.command: pg_isready -h localhost
register: db_check
failed_when: "'no response' in db_check.stderr"
# ─── Never fail (handle errors yourself) ───
- name: Try to reach external service
ansible.builtin.uri:
url: "https://api.example.com/health"
timeout: 5
register: api_check
failed_when: false # Never fails — check result manually
- name: Handle API result
ansible.builtin.debug:
msg: "API is {{ 'up' if api_check.status == 200 else 'down' }}"
# ─── Multiple failure conditions ───
- name: Deploy application
ansible.builtin.command: /opt/deploy.sh
register: deploy
failed_when:
- deploy.rc != 0
- "'ROLLBACK' not in deploy.stdout"
# Only fails if rc≠0 AND no rollback message
# (Successful rollback = rc≠0 but not a failure)
# ─── Custom error detection ───
- name: Run SQL migration
ansible.builtin.command: psql -f migration.sql
register: sql_result
failed_when: >
sql_result.rc != 0 or
'ERROR' in sql_result.stderr or
'FATAL' in sql_result.stderr
Combined Patterns
# ─── Read-only command: never changed, handle failure ───
- name: Check disk space
ansible.builtin.command: df -h /data
register: disk
changed_when: false
failed_when: false
- name: Alert if disk full
ansible.builtin.fail:
msg: "/data is over 90% full"
when: disk.stdout_lines[1].split()[4] | replace('%','') | int > 90
# ─── Idempotent shell script ───
- name: Initialize database
ansible.builtin.shell: |
if psql -lqt | cut -d \| -f 1 | grep -qw mydb; then
echo "EXISTS"
else
createdb mydb
echo "CREATED"
fi
register: db_init
changed_when: "'CREATED' in db_init.stdout"
failed_when: db_init.rc != 0 and 'EXISTS' not in db_init.stdout
# ─── Package version check ───
- name: Check if update available
ansible.builtin.command: apt list --upgradable nginx
register: apt_check
changed_when: false
failed_when: false
- name: Upgrade nginx if available
ansible.builtin.apt:
name: nginx
state: latest
when: "'nginx' in apt_check.stdout"
# ─── Service restart only if config changed ───
- name: Deploy configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/config.yml
register: config_result
- name: Validate configuration
ansible.builtin.command: myapp validate-config
changed_when: false
when: config_result.changed
- name: Restart application
ansible.builtin.systemd:
name: myapp
state: restarted
when: config_result.changed
Real-World Examples
# ─── Docker: only changed when image pulled ───
- name: Pull latest image
ansible.builtin.command: "docker pull {{ image }}"
register: pull
changed_when: "'Downloaded newer image' in pull.stdout or 'Pull complete' in pull.stdout"
# ─── Terraform: parse output for changes ───
- name: Apply Terraform
ansible.builtin.command: terraform apply -auto-approve
args:
chdir: /opt/terraform/infra
register: tf_result
changed_when: "'Apply complete! Resources: 0 added, 0 changed, 0 destroyed.' not in tf_result.stdout"
# ─── GPG key import: idempotent ───
- name: Import GPG key
ansible.builtin.command: "gpg --import /tmp/key.gpg"
register: gpg
changed_when: "'imported' in gpg.stderr"
failed_when:
- gpg.rc != 0
- "'already in secret keyring' not in gpg.stderr"
# ─── AWS CLI: check before create ───
- name: Create S3 bucket
ansible.builtin.shell: |
if aws s3api head-bucket --bucket {{ bucket_name }} 2>/dev/null; then
echo "EXISTS"
else
aws s3api create-bucket --bucket {{ bucket_name }} --region {{ region }}
echo "CREATED"
fi
register: s3_result
changed_when: "'CREATED' in s3_result.stdout"
failed_when: s3_result.rc != 0 and 'EXISTS' not in s3_result.stdout
Common Mistakes
# ❌ MISTAKE 1: changed_when with modules that already track changes
- name: Redundant
ansible.builtin.apt:
name: nginx
state: present
changed_when: false # DON'T — apt module already reports correctly
# FIX: Only use changed_when with command/shell/raw
# ❌ MISTAKE 2: Using failed_when: false without handling errors
- name: Dangerous — silently ignores all errors
ansible.builtin.command: rm -rf /important/data
failed_when: false
# FIX: Always check register result after failed_when: false
# ❌ MISTAKE 3: Forgetting register with changed_when
- name: No register — can't check output
ansible.builtin.command: myapp deploy
changed_when: "'deployed' in result.stdout" # ERROR: result is undefined
# FIX: Add register: result
# ❌ MISTAKE 4: changed_when string vs boolean
- name: Wrong — string 'false' is truthy
ansible.builtin.command: echo hello
changed_when: "false" # This is a string — still works but confusing
# FIX: Use boolean
changed_when: false
Best Practices
# 1. Always add changed_when: false to read-only commands
- name: Gather info
ansible.builtin.command: cat /etc/os-release
register: os_info
changed_when: false
# 2. Use meaningful changed detection
- name: Clear — reports changed only when upgrade occurs
ansible.builtin.command: pip install --upgrade myapp
register: pip_result
changed_when: "'Successfully installed' in pip_result.stdout"
# 3. Prefer modules over command + changed_when
# Instead of:
- ansible.builtin.command: useradd appuser
changed_when: ...
failed_when: ...
# Use:
- ansible.builtin.user:
name: appuser
state: present
# 4. Document complex conditions
- name: Deploy app (changed only on actual deployment)
ansible.builtin.command: /opt/deploy.sh --check-and-deploy
register: deploy
# deploy.sh outputs "NO_CHANGES" when current version matches
# and "DEPLOYED vX.Y.Z" when a new version is deployed
changed_when: "'DEPLOYED' in deploy.stdout"
failed_when: deploy.rc != 0 and 'ROLLBACK_OK' not in deploy.stdout
Related Articles
- Ansible Error Handling
- Ansible Handlers
- Ansible handlers vs post_tasks
- Ansible Troubleshooting
- Ansible Playbook Best Practices
Conclusion
changed_when and failed_when are essential for making command/shell tasks behave like proper Ansible modules. Use changed_when: false on every read-only command. Use output-based changed_when to make shell scripts report changes accurately. Use failed_when to handle expected non-zero return codes. The goal: every task in --check mode and every play recap should reflect reality — not module defaults.