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 --checkmode: 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:
- Trigger handlers unnecessarily (restarting services when nothing changed)
- Make
--checkoutput unreliable - Hide real changes in noisy output
- 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:
| Directive | Controls | Default |
|---|---|---|
changed_when | When task reports changed | Always changed for command/shell |
failed_when | When task reports failed | rc != 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
- Always add
changed_when: falseto read-only commands —cat,ls,date,whoami,uname, status checks - Use dedicated modules when available —
aptoverapt install,fileovermkdir,useroveruseradd - Check command output for change indicators — "created", "updated", "modified", "no changes"
- Combine
changed_whenwithfailed_whenfor robust error handling - Use
registerto capture output for change detection - Test with
--check --diffto verify yourchanged_whenlogic is accurate
Related Articles
- Ansible changed_when failed_when Guide
- Ansible Lint Guide
- Ansible shell Module Guide
- Ansible Playbook Best Practices
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.