An Ansible playbook you can re-run without risk, again and again, with the same result every time: that is the promise of idempotence, the same requirement as for infrastructure as code in the broad sense, and it is also the reason Ansible took over server configuration where a bash script accumulates side effects on every run. That promise holds very well for declarative modules, and much less well as soon as a playbook contains a shell or command task.

What makes a module genuinely idempotent

A well-designed Ansible module does not describe an action, it describes a desired state, and compares that state to reality before acting.

- name: Ensure nginx is installed
  ansible.builtin.package:
    name: nginx
    state: present

This task first checks whether the package is already installed. If it is, it does nothing and reports itself as ok (not changed). That is only possible because the package module knows how to query the underlying package manager for the real state before deciding whether it has to act. The distinction between ok and changed in a playbook’s output is the most reliable signal for spotting a module that does not really check state before acting: a genuinely idempotent module reports changed only the first time, never on subsequent runs.

The trap: shell and command have no notion of idempotence

The shell and command modules literally execute what they are given, with no state comparison logic whatsoever. A task like this one runs every single time, no exceptions:

- name: Add a line to a config file
  ansible.builtin.shell: echo "max_connections = 200" >> /etc/app/config.ini

Replayed ten times, this task appends the line ten times. It reports itself as changed on every run, which is at least honest about what actually happened, but the final state of the file depends on how many times the playbook ran, not on its definition. The lineinfile module, designed specifically for this case, checks whether the line already exists before adding it:

- name: Add a line to a config file (idempotent)
  ansible.builtin.lineinfile:
    path: /etc/app/config.ini
    line: "max_connections = 200"

Check mode does not catch this problem

ansible-playbook --check simulates the run without changing anything for real, and reports what would have changed. It is a valuable tool for previewing the effect of a playbook before applying it in production, but it has a limit that regularly traps teams who trust it too much: the shell and command modules have no way of predicting their own effect without actually running, so Ansible treats them as “unknown” during a check. Depending on the version and the context, that translates either into a silent skip of the task during the check (which gives a false sense of security: the check passes while the real run would do something), or into a systematic changed marking even when nothing would actually change (which produces the opposite effect, a false positive that raises an alarm for no reason).

A simple rule for sorting your tasks

The practical reflex: look for a dedicated module before writing a shell or command task. Nearly every common configuration management operation has a native equivalent: lineinfile or blockinfile to edit a file, copy or template to drop one in place, systemd to manage a service, user and group for account management. When no native module fits and a shell command remains necessary, two options limit the damage: adding a creates: or removes: condition, which makes the task conditional on the presence or absence of a marker file, or writing the command itself in an idempotent way (a mkdir -p rather than a mkdir, for example, which would fail on a second run).

- name: Initialise the database only once
  ansible.builtin.command: /opt/app/init-db.sh
  args:
    creates: /opt/app/.db-initialized

Where this fits

A pipeline that runs playbooks to provision or configure an infrastructure inherits all the side effects of non-idempotent tasks on every new run of the same job, which makes the pipeline’s behaviour depend on its execution history rather than on its definition; it is the same risk of silent drift as a badly managed Terraform state, on an entirely different tool. Making this kind of detail reliable is part of what a CI/CD industrialisation engagement deals with.

Takeaway

The idempotence of an Ansible module comes from its ability to compare the desired state to the real state before acting; the shell and command modules have no notion of that state and run every time, unconditionally. Check mode does not catch that problem: it treats those modules as a grey area, producing either a false sense of security or a false positive depending on the case. Looking for a dedicated module before writing a shell command remains the best protection.