SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Ansible check mode and --diff dry runs

Ansible check mode explained: what --check and --diff really prove, and where a dry run gives you a wrong answer before you apply for real.

What Ansible check mode does

Ansible check mode is a dry run: ansible-playbook --check connects to every host in the play, asks each module whether the current state already matches the state you asked for, and reports what would change without writing anything. Add --diff and it also prints the before and after content of the files it would touch. Together they answer the question worth asking before every real run: what is about to change on these servers?

Check mode is not a simulation of your playbook. There is no model of the server anywhere. Each module is simply asked to look instead of write. A module that can answer read-only reports changed and moves on. A module that cannot answer does nothing and reports nothing. The Ansible documentation puts it in one line: "Modules that do not support check mode report nothing and do nothing." That gap is where a dry run gives you a wrong answer, so most of this guide is about the gap.

Run the dry run: --check and --diff

ansible-playbook -i inventory.ini site.yml --check --diff --limit web1

-C and -D are the short forms of the two flags. The --limit is deliberate. One host's diff is something you can read. Twenty hosts of diff is something you scroll past.

Four result words carry the whole report.

  • ok: [web1] means the module looked and the state already matches. Nothing would change.
  • changed: [web1] means the module would have written something. With --diff, the lines above it show what.
  • skipping: [web1] means the task was not evaluated. Either a when was false, or the module cannot run in check mode.
  • fatal: [web1] means the task failed while checking. Read the message before you assume the playbook is broken.

--diff prints a unified diff for file modules, with removed lines marked - and added lines marked +, under a header whose lines start with --- before and +++ after and name the destination path. Modules that do not write files print their own before and after, so ansible.builtin.user shows the attributes it would change rather than file content.

Turn diff on permanently in ansible.cfg so you never forget the flag:

[diff]
always = true
context = 5

Two cheaper checks belong in front of check mode. ansible-playbook site.yml --syntax-check parses the YAML and the play structure without contacting a single host. ansible-playbook site.yml --list-tasks prints the tasks that would run, which is how you find out that a role you thought was tagged is not. Neither one connects, so both are instant.

Check mode itself does connect. It opens SSH to every host in the pattern and gathers facts, so a host that is down fails the dry run. That is a useful signal by itself, and it is also why deciding what a playbook should do about unreachable hosts matters before you put a dry run in CI.

Why check mode fails on a fresh server

This play is correct. Run it with --check against a server that does not have nginx yet, and most of it fails.

- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present

- name: Write the site config
  ansible.builtin.template:
    src: site.conf.j2
    dest: /etc/nginx/conf.d/site.conf

- name: Start and enable nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

The apt task reports changed, and it is right: the package is absent, so a real run would install it. Check mode did not install it. The template task then fails, because /etc/nginx/conf.d/ does not exist on this host and nothing created it. The service task fails as well, because there is no nginx unit for it to query. Neither failure is a bug in the playbook. The dry run ran out of the state it needed, which is what the documentation means when it warns that check mode cannot produce useful output for a task whose input depends on a prior task's change.

So the honest version of the rule: check mode is accurate against a host the playbook has already converged, and noisy against a fresh one. A --check run where every task reports ok is a real statement about a converged host, because it means nothing would change. On a brand new host, --check mostly tells you the host is new. When you write your first Ansible playbook against a VPS, expect the first dry run to be a wall of red, and judge the playbook by the second one.

Why command and shell tasks are skipped in check mode

ansible.builtin.command and ansible.builtin.shell have no idea what your command does. There is no read-only way to run an arbitrary binary, so in check mode the module refuses to run it. The task result carries skipped: true and the message Command would have run if not in check mode, and your output shows skipping: [web1].

The module documentation calls its check mode support "partial", and the workaround it names is creates and removes. Give the task a creates path and check mode can at least evaluate the file test:

- name: Extract the release bundle
  ansible.builtin.command: /usr/bin/tar xf /tmp/app.tar.gz -C /opt/app
  args:
    creates: /opt/app/bin/app

If /opt/app/bin/app already exists, check mode reports Would not run command since '/opt/app/bin/app' exists, which is a genuine answer. If the path is missing, you get Command would have run if not in check mode, which is also a genuine answer. Without creates, that task is a blank space in your dry run.

The knock-on effect is worse than the blank space. A skipped task still registers a result, but the result is a skip result and it has no stdout key. The next task's condition then fails while it is being evaluated, with an error close to 'dict object' has no attribute 'stdout'. Your playbook works in a real run and breaks in the dry run, which is the most confusing failure in this whole feature.

check_mode: false, and the one place it belongs

check_mode: false on a task means "run this for real, even under --check". It is the fix for the skipped-command problem, and it is safe only on a task that reads.

- name: Read the installed app version
  ansible.builtin.command: /usr/local/bin/app --version
  register: app_version
  check_mode: false
  changed_when: false

That task is honest in both modes. It reads a version and never writes, changed_when: false stops it reporting a change it did not make, and check_mode: false makes app_version.stdout exist during a dry run, so the conditions built on it still evaluate.

Read the keyword literally before you paste it anywhere else. A task with check_mode: false writes to your servers during ansible-playbook --check. Put it on an apt task or a template task to make a dry run look tidier and your dry run is no longer a dry run. When a writing task cannot be made safe, guard it instead:

- name: Apply the database migration
  ansible.builtin.command: /usr/local/bin/app migrate --apply
  when: not ansible_check_mode

ansible_check_mode is a magic variable that Ansible sets to true during a check run. The reverse keyword exists too. check_mode: true pins a task to check mode always, even during a real run, which turns it into a drift probe: register the result, and a changed report means the host no longer matches what the task asks for.

Why a task reports changed on every run

Run the playbook twice, back to back, with nothing in between. Every task should report ok on the second run. Any task still reporting changed is telling you one of two things: the module cannot see the state it manages, or the input you feed it is not stable. Both are fixable, and neither is noise to be silenced.

  • command and shell with no creates, removes or changed_when report changed every single time, because the module has no way to know whether anything happened. Add creates, or set changed_when against a string in the output.
  • ansible.builtin.file with state: touch reports changed on every run by design, because touching a file updates its timestamps. Use state: file if all you wanted was to set the owner or the mode.
  • A template whose rendered output moves rewrites the file every run. A timestamp from ansible_date_time, a call to now(), or a password generated fresh each time all produce different bytes, so the module correctly reports a change. Take the moving value out of the template.
  • ansible.builtin.user with password: "{{ pw | password_hash('sha512') }}" changes every run, because password_hash picks a random salt each time it is called, so the resulting hash never matches the one already in /etc/shadow. Pass an explicit salt derived from something stable.
  • state: latest on a package module reports changed whenever an upgrade is available. That one is honest. It is also why state: latest gives you a playbook whose result you cannot predict. Use state: present and upgrade on purpose.
  • ansible.builtin.unarchive pointed at a URL with no creates re-fetches and re-extracts. Give it a creates path.

--diff is the fastest way to tell these apart. If a task says changed and the diff shows bytes that differ, your input is unstable. If it says changed and the diff shows nothing at all, the module cannot express what it changed, which usually means a command task or a metadata-only write like a timestamp.

Do not reach for changed_when: false to quiet a noisy task. It suppresses the report, so notify never fires and the handler that restarts the service never runs. Fix the task instead.

Shrink the blast radius: --limit, --tags and --step

Check mode tells you what would change. These flags decide how many machines find out at once.

--limit narrows the play to a subset of the inventory. It takes the same patterns as hosts:, so both --limit web1 and --limit 'webservers:!web3' work. Quote the pattern. An unquoted ! in an interactive bash session triggers history expansion on the exclamation mark, and your shell rewrites the command before Ansible ever sees it.

Confirm the pattern before you trust it. ansible-playbook site.yml --limit 'webservers:!web3' --list-hosts prints the matched hosts and exits without connecting to any of them. A pattern that matches nothing is safe, because Ansible does not fall back to the whole inventory. It prints a warning that it could not match the host pattern, then exits with an error saying the hosts and --limit do not match any hosts. Knowing how the inventory file defines those groups is what makes a pattern predictable in the first place.

--tags deploy runs only the tagged tasks, and --skip-tags packages runs everything else. --list-tags prints what is available. Tags earn their keep once a play grows past the point where you are willing to run all of it, which is also one of the reasons for splitting a long playbook into roles.

--start-at-task "Write the site config" resumes a failed run from a named task. Use it to recover, and understand what it costs: everything before that task is skipped, including tasks that set facts or register the variables later tasks read.

--step prompts before each task and waits for you to answer yes, no, or continue. It is slow, and it is the right tool the first time you run something destructive, because you can stop between two tasks instead of after twenty.

Roll the change out with serial

By default Ansible runs one task against every host in the play before it starts the next task. That is fast, and it means a bad task reaches the whole fleet in the same second. By the time you have read the error and pressed Ctrl-C, the change is already everywhere.

serial breaks the play into batches. The entire play runs against the first batch, then the next.

- name: Roll out the web tier
  hosts: webservers
  serial: [1, 5, "30%"]
  max_fail_percentage: 0
  tasks:
    - name: Deploy the release
      ansible.builtin.include_role:
        name: webapp

The first batch is one host. If it survives, the second batch is five, and every batch after that is 30 percent of the play's hosts. max_fail_percentage: 0 ends the play as soon as any host in a batch fails, so a broken release stops at one machine. any_errors_fatal: true is the blunter version, ending the play for everyone on the first host failure.

Running against one host first is not paranoia, and the reason is specific. Inventory groups drift. A server added six months after the others may run a different distribution release, or carry a service somebody installed by hand, or have its disks laid out differently. The playbook is correct for the group and wrong for that one host, and no dry run against a converged host will show it. Managing a fleet of Linux servers is largely the practice of finding the odd host before the change does.

The order to run things in

  1. ansible-playbook site.yml --syntax-check catches YAML and structure mistakes with no network at all.
  2. ansible-playbook site.yml --limit web1 --list-hosts proves your pattern matches what you think it matches.
  3. ansible-playbook site.yml --limit web1 --check --diff is the dry run. Read the diff.
  4. ansible-playbook site.yml --limit web1 --diff applies it to that one host.
  5. Run step 4 again. Everything should report ok. Anything still changed is a task to fix before it touches the rest of the fleet.
  6. ansible-playbook site.yml --check --diff across the whole inventory now returns a meaningful answer, because the converged hosts are quiet and what remains is the real delta.

One warning about step 3. --diff prints file contents to your terminal and into your CI job log, so a template that renders a database password renders that password into the log. Set diff: false on that task to suppress its output, or no_log: true to hide the whole result, and keep the value itself in an encrypted Ansible Vault file rather than in the repository.

FAQ

Does ansible-playbook --check change anything on the server?

No, with one exception that you control. In check mode every module is asked to report instead of write, and modules that cannot do that report nothing and do nothing. The exception is the check_mode: false task keyword, which forces that single task to execute for real even during a --check run. Search your playbooks and roles for check_mode: false before you trust a dry run, and confirm every match is a task that only reads state.

What is the difference between --check and --diff?

--check decides whether anything runs for real. --diff decides how much detail you see. --check on its own tells you that a file would change. --diff on its own applies the change and shows you the lines it changed. Use them together for a dry run you can actually read, and leave --diff on for real runs too by setting always = true under [diff] in ansible.cfg.

Why does my Ansible task report changed on every run?

Because the module cannot see the state it manages, or the value you hand it is different each time. command and shell report changed always unless you add creates or changed_when. file with state: touch changes by design. A template that renders a timestamp or a freshly generated password produces different bytes each run, so the file really is being rewritten. Run the playbook twice in a row: anything still changed on the second pass is the task to fix.

Why are my command and shell tasks skipped during a dry run?

Because there is no read-only way to run an arbitrary command. In check mode the command module sets skipped: true with the message Command would have run if not in check mode. Add creates or removes so check mode can evaluate the file test instead. For a task that only reads state, set check_mode: false together with changed_when: false, so the registered result still exists during the dry run and the conditions built on it keep working.

Why does check mode fail on a new server but pass on an existing one?

Because check mode does not create the state that later tasks depend on. A dry run against a host without nginx reports the install as changed, then fails on the task that writes into /etc/nginx/conf.d/, because that directory was never created. This is expected behaviour. Check mode is a drift detector for hosts the playbook has already converged. It cannot validate a first run. On a new host, apply the playbook to one machine and read the second run instead.

#ansible#check-mode#idempotency#automation#safety