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

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.