Choose the Module, Not the Command

Scenario: A deploy playbook builds its config with shell: echo ... >> app.conf. After six months the file has 174 lines, 170 of which are duplicates, and the application reads whichever it finds first.

New words, in plain English

A module knows what it is trying to achieve, so it can check reality first. copy computes a checksum of the intended content and the existing file, and does absolutely nothing if they match. file with state: directory checks whether the directory already exists with the right mode. This is what "idempotent" means in practice.

shell and command know nothing. echo x >> f appends unconditionally, every single run, forever. mkdir -p d at least does not fail, but it still reports changed every time, and anything that depends on that result - most importantly a handler - fires every time too.

The rule is simple and worth applying strictly: if a module exists for the job, use it. file instead of mkdir/chmod/rm, copy or template instead of echo/cat, lineinfile/blockinfile for surgical edits to files you do not fully own, service/systemd instead of systemctl, user/group instead of useradd.

Analogy: copy is a proofreader who compares your draft with the printed page and only reprints if they differ. echo >> is a printing press with the safety removed - it runs off another copy every time you walk past, whether or not one is needed.

A worked example

# WRONG - appends forever, reports changed forever
- name: Write the settings
  ansible.builtin.shell: |
    mkdir -p ~/srv/checkout
    echo "listen_port = 8080" >> ~/srv/checkout/config.ini

# RIGHT - declares the end state, converges once, then does nothing
- name: The application directory exists
  ansible.builtin.file:
    path: ~/srv/checkout
    state: directory
    mode: "0750"

- name: The settings file has exactly this content
  ansible.builtin.copy:
    dest: ~/srv/checkout/config.ini
    content: |
      listen_port = 8080
      worker_count = 4
    mode: "0644"

There is a second, quieter benefit. Because modules report what they did in a structured way, --check and --diff work properly with them: Ansible can tell you exactly which lines of a file would change before you change them. A shell task can offer none of that - in check mode it is simply skipped, and you learn nothing.

When you genuinely must run a command - a vendor installer, a bespoke migration tool, a health probe - that is legitimate. The next section is about making those tasks behave.

Warning: content: in copy replaces the ENTIRE file. That is exactly what you want for a file your automation owns, and exactly what you do not want for /etc/hosts. For files you share with the system, use lineinfile or blockinfile.
Goal: Put this to work in the ansible-idempotent-modules lab. Open /labs/ansible, pick ansible-idempotent-modules, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.