Introduction

Ansible Lint rule 301 (no-changed-when) flags tasks that execute commands without defining when the task should be considered "changed." This primarily affects the command, shell, raw, and script modules, which always report changed: true by default — even when they perform read-only operations. This article explains why this matters, every strategy for fixing it, and how changed_when enables truly idempotent playbooks.

Why This Matters

Ansible's change tracking is fundamental to:

  • Handlers: Only triggered when a notifying task reports changed
  • --check mode: Accurate dry-run behavior
  • Audit logs: Know what actually changed during a run
  • Idempotency: Running a playbook twice should produce the same result

When command/shell tasks always report changed, you get false positives that:

  1. Trigger handlers unnecessarily (restarting services when nothing changed)
  2. Make --check output unreliable
  3. Hide real changes in noisy output
  4. Break idempotency assumptions

The Error

Problematic Code

---
- name: Example playbook
  hosts: all
  tasks:
    - name: Check current date
      ansible.builtin.command: date

Lint Output

$ ansible-lint playbook.yml
WARNING  Listing 1 violation(s) that are fatal
no-changed-when: Commands should not change things if nothing needs doing.
playbook.yml:5 Task/Handler: Check current date

                  Rule Violation Summary
 count tag             profile rule associated tags
     1 no-changed-when shared  command-shell, idempotency

Failed: 1 failure(s), 0 warning(s) on 1 files.

Solution 1: changed_when with Return Code

The most common pattern — use the command's return code:

- name: Check if service is running
  ansible.builtin.command: systemctl is-active nginx
  register: nginx_status
  changed_when: false  # This is a read-only check
  failed_when: nginx_status.rc not in [0, 3]

Solution 2: changed_when: false for Read-Only Commands

When a command never changes anything:

- name: Get current kernel version
  ansible.builtin.command: uname -r
  register: kernel_version
  changed_when: false

- name: Check disk usage
  ansible.builtin.command: df -h /
  register: disk_usage
  changed_when: false

- name: Read file contents
  ansible.builtin.command: cat /etc/hostname
  register: hostname_content
  changed_when: false

Solution 3: changed_when Based on Output

Check command output to determine if something actually changed:

- name: Add line to config if not present
  ansible.builtin.shell: |
    grep -q "^MaxSessions 10" /etc/ssh/sshd_config || echo "MaxSessions 10" >> /etc/ssh/sshd_config
  register: sshd_result
  changed_when: "'MaxSessions 10' not in sshd_result.stdout"

- name: Create database if not exists
  ansible.builtin.command: createdb myapp
  register: createdb_result
  changed_when: "'already exists' not in createdb_result.stderr"
  failed_when:
    - createdb_result.rc != 0
    - "'already exists' not in createdb_result.stderr"

Solution 4: changed_when with Register

Combine register and changed_when for complex logic:

- name: Check if reboot is required
  ansible.builtin.stat:
    path: /var/run/reboot-required
  register: reboot_file

- name: Run update script
  ansible.builtin.command: /opt/scripts/update.sh
  register: update_result
  changed_when: "'Updated' in update_result.stdout"
  notify: restart application

Solution 5: Use a Dedicated Module Instead

Often, the best fix is to replace command/shell with a purpose-built module:

# ❌ Using command (triggers no-changed-when)
- name: Install nginx
  ansible.builtin.command: apt install -y nginx

# ✅ Using the apt module (handles changed detection automatically)
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present

# ❌ Using shell for file operations
- name: Create directory
  ansible.builtin.shell: mkdir -p /opt/myapp

# ✅ Using the file module
- name: Create directory
  ansible.builtin.file:
    path: /opt/myapp
    state: directory
    mode: "0755"

# ❌ Using command for user creation
- name: Add user
  ansible.builtin.command: useradd deploy

# ✅ Using the user module
- name: Add user
  ansible.builtin.user:
    name: deploy
    state: present

Common Patterns by Use Case

System Information Gathering

- name: Get OS release info
  ansible.builtin.command: cat /etc/os-release
  register: os_info
  changed_when: false

- name: Get IP addresses
  ansible.builtin.command: hostname -I
  register: ip_addresses
  changed_when: false

Conditional Execution

- name: Check if migration is needed
  ansible.builtin.command: python3 manage.py showmigrations --plan
  register: migrations
  changed_when: false

- name: Run migrations
  ansible.builtin.command: python3 manage.py migrate --noinput
  register: migrate_result
  changed_when: "'Applying' in migrate_result.stdout"
  when: "'[ ]' in migrations.stdout"

Script Execution

- name: Run deployment script
  ansible.builtin.script: deploy.sh
  register: deploy_result
  changed_when: deploy_result.rc == 0
  failed_when: deploy_result.rc > 1

Database Operations

- name: Check if table exists
  ansible.builtin.command: >
    psql -U postgres -d mydb -c
    "SELECT to_regclass('public.users')"
  register: table_check
  changed_when: false

- name: Run SQL migration
  ansible.builtin.command: psql -U postgres -d mydb -f /opt/migrations/001.sql
  register: sql_result
  changed_when: "'CREATE' in sql_result.stdout or 'ALTER' in sql_result.stdout"
  when: "'users' not in table_check.stdout"

Using changed_when with Handlers

changed_when directly controls handler notification:

---
- name: Configure and restart service
  hosts: all
  become: true
  tasks:
    - name: Update configuration
      ansible.builtin.shell: |
        /opt/app/configure.sh --apply
      register: config_result
      changed_when: "'Configuration updated' in config_result.stdout"
      notify: restart app

  handlers:
    - name: restart app
      ansible.builtin.service:
        name: myapp
        state: restarted

The handler only fires when the script reports "Configuration updated" — not on every run.

changed_when vs failed_when

These are complementary but serve different purposes:

DirectiveControlsDefault
changed_whenWhen task reports changedAlways changed for command/shell
failed_whenWhen task reports failedrc != 0 for command/shell

You can use both together:

- name: Create database
  ansible.builtin.command: createdb myapp
  register: result
  changed_when: result.rc == 0
  failed_when:
    - result.rc != 0
    - "'already exists' not in result.stderr"

Applying to Handlers

The rule applies to handlers too:

# ❌ Handler without changed_when
handlers:
  - name: reload config
    ansible.builtin.command: /opt/app/reload.sh

# ✅ Handler with changed_when
handlers:
  - name: reload config
    ansible.builtin.command: /opt/app/reload.sh
    register: reload_result
    changed_when: reload_result.rc == 0

Best Practices

  1. Always add changed_when: false to read-only commands — cat, ls, date, whoami, uname, status checks
  2. Use dedicated modules when available — apt over apt install, file over mkdir, user over useradd
  3. Check command output for change indicators — "created", "updated", "modified", "no changes"
  4. Combine changed_when with failed_when for robust error handling
  5. Use register to capture output for change detection
  6. Test with --check --diff to verify your changed_when logic is accurate

Conclusion

Ansible Lint Error 301 (no-changed-when) ensures that command, shell, raw, and script tasks accurately report whether they changed the system. The fix depends on the task's purpose: use changed_when: false for read-only commands, check output content for conditional changes, or replace the command with a purpose-built module. Accurate change reporting is the foundation of idempotent, handler-aware, audit-friendly playbooks.