Skip to main content
RunBook Academy

AnsibleXXXVI · Drift and ConvergenceDrift and convergence

Reading diff output properly

Advanced⏱ ~21 minansible-playbook

What you'll learn

  • Read a --diff hunk correctly, including what the before and after labels point at
  • Predict which modules produce a diff and which produce nothing, from their diff_mode attribute
  • Weigh the disclosure risk of a diff against its value as a review artefact
  • Recognise the diff patterns that indicate a defective declaration rather than real drift

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

changed=3 tells you that three declarations do not match. --diff tells you what does not match, in the only form that lets a human judge whether the difference is a problem.

It is the best review surface Ansible has, and it is incomplete in ways that are not obvious from looking at the output.

What a hunk actually says

Read-only / Safea copy task, in check and diff mode
$ ansible-playbook -i inventory.ini site.yml --check --diff
TASK [converge the config] *****************************************************
--- before: /etc/app/live.conf
+++ after: /srv/automation/files/desired.conf
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 256
Timeout 60

changed: [localhost]

Reading it precisely:

  • before: is the file on the managed host, as it is now.
  • after: is the content that would be written. For ansible.builtin.copy that label is the source path on the controller, not the destination.
  • - lines disappear, + lines appear, unprefixed lines are context.
  • The direction is always “current state on the left, declared state on the right”, which is the opposite of a Git diff reviewing a proposed change to the declaration itself. Both appear in a normal working day and it is worth being conscious of which one you are looking at.

For ansible.builtin.template the after: label is stranger still:

Read-only / Safea template task, in check and diff mode
$ ansible-playbook -i inventory.ini site.yml --check --diff
TASK [render config] ***********************************************************
--- before: /etc/app/live.conf
+++ after: /home/deploy/.ansible/tmp/ansible-local-282623jc626hgd/tmpe6rtyo04/app.conf.j2
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 512
Timeout 60

changed: [localhost]

That path is real. The template is rendered on the controller, into ~/.ansible/tmp, and then compared. Which means a --check --diff run writes the fully rendered configuration — including any secrets it contains — to the controller’s local disk, even though it changes nothing on the target.

Which modules produce a diff at all

diff_mode is a documented attribute, exactly like check_mode, and it varies more than people expect. Read on 2.21.3:

Modulediff_mode support
template, copy, lineinfile, blockinfile, replace, assemble, cron, apt, dnffull
file, unarchivepartial
command, shell, systemd_service, user, group, get_url, wait_for, stat, setupnone
service, packageN/A (action plugins that dispatch to a concrete module)
Read-only / Safecheck before you rely on it
ansible-doc ansible.builtin.file | sed -n '/diff_mode/,+3p'
ansible-doc ansible.builtin.systemd_service | sed -n '/diff_mode/,+3p'

The consequence for a drift report: a task can report changed with no diff at all. systemd_service restarting a unit, user altering a shell, get_url fetching a file — all report a change and show you nothing about what the change is.

So a drift report is two different documents stapled together: the part with diffs, which a reviewer can judge, and the part without, which is just a list of task names. Knowing which findings fall in which half is the difference between reviewing a change and rubber-stamping it.

no_log deletes the diff

The interaction is complete rather than partial, and it is worth seeing:

Read-only / Safethe same task, with and without no_log
$ ansible-playbook -i inventory.ini nolog.yml --check --diff
TASK [secret-bearing config, no_log on] ****************************************
changed: [localhost]

TASK [same, no_log off] ********************************************************
--- before: /etc/app/live.conf
+++ after: /srv/automation/files/desired.conf
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 256
Timeout 60

changed: [localhost]

no_log: true gives you changed and nothing else. That is exactly what it is for, and it creates a genuine conflict:

  • Without no_log, a diff of a rendered template prints its contents — database passwords, API tokens, private keys — into the run log, and from there into CI output, chat notifications, and log aggregation, where they are retained and searchable.
  • With no_log, the most sensitive files in your estate are exactly the ones nobody can review, and drift in them is reported as a count with no detail.

Diff patterns that are not drift

A useful skill is recognising the diffs that indicate a defective declaration rather than a changed host. Four common ones:

A timestamp in a managed header. A template whose header renders Generated at {{ ansible_date_time.iso8601 }} differs on every single run, on every host, forever. The diff is one line and it is always there. This one also breaks handlers: the file is rewritten every run, so the notify fires every run, and the service is restarted nightly for no reason.

Whitespace and line-ending churn. A diff where every line is both removed and added is usually a trailing-newline or CRLF difference, not a content change. Look at the hunk header: @@ -1,40 +1,40 @@ on a file nobody edited is the signature.

Reordered lines. A template iterating a dictionary can emit keys in a different order than the file on disk has them. The content is equivalent and the diff is large. Sort explicitly in the template.

The whole file as additions. @@ -0,0 +1,120 @@ means the file does not exist on the host. That is not drift in the “someone edited it” sense; it is a host that has never had this configuration, which is usually either a new host or a when condition that has been excluding it.

Read-only / Safecapture the diff as the reviewable artefact
ansible-playbook -i inventory/ site.yml \
--limit wave1_canary \
--check --diff \
| tee "artifacts/diff-$(date +%Y%m%dT%H%M%S)-$(git rev-parse --short HEAD).txt"

Naming the file after the commit matters for the same reason it did in the runtime-budget work: a diff is only interpretable against the version of the content that produced it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. In a --check --diff run of a template task, what does the "+++ after:" path point at?

  2. Q2. A drift report shows changed on a systemd_service task, with no diff beneath it. What does that mean?

  3. Q3. Which of these diffs suggest a defective declaration rather than a host that has drifted? Select all that apply.

  4. Q4. Putting no_log: true on a task that manages a config file containing one password is the right way to balance reviewability against disclosure.

Passing score: 75%. Answers are checked in this browser.