Linux command: diff
Two files that should be the same are not. A configuration that worked yesterday does not work today, a restored backup might be missing something, or a supplier sent you a "small fix" for a file you have already edited. The command that answers all three questions is diff, and its output is not a report for you to read. It is a set of instructions that another program can carry out.
1. The Basics
diff compares two files line by line and describes what you would have to change in the first one to turn it into the second. That direction matters: a diff is not a neutral list of differences, it is a recipe with a from and a to.
1.1 The Simplest Possible Use
Two versions of a small configuration file:
$ diff old.php new.php
3c3
< 'host' => 'localhost',
---
> 'host' => 'db.internal',
5c5
< 'debug' => false,
---
> 'debug' => true,
This is the original 1974 output format, and it reads like a set of edits:
| Part | Means |
|---|---|
3c3 |
Line 3 of the first file changes into line 3 of the second |
< |
A line from the first file |
> |
A line from the second file |
--- |
The divider between the two sides |
Two other letters appear in place of c: a for lines added and d for lines deleted. So 7a8 means "after line 7 of the old file, add what becomes line 8 of the new one".
You will rarely use this format on purpose. Section 4 covers the two you actually want. It is worth recognising, because it is what you get when you forget to ask for anything else.
1.2 The Exit Code Is the Point
The output is for humans. The exit code is what makes diff useful in a script, and it has three values:
$ diff old.php old.php >/dev/null ; echo $?
0 # the files are identical
$ diff old.php new.php >/dev/null ; echo $?
1 # they differ
$ diff old.php nosuch.php >/dev/null 2>&1 ; echo $?
2 # something went wrong
That 2 is the one people forget. A missing file, an unreadable directory or a bad option all produce it, and a script that treats "not zero" as "the files differ" will report a difference that was really a typo in a path.
if diff -q "$a" "$b" >/dev/null; then
echo "identical"
elif [ $? -eq 1 ]; then
echo "they differ"
else
echo "diff itself failed"
fi
Back to topThe right mental model:
diffdoes not tell you that two files are different. It writes down the instructions for turning the first into the second, in a format precise enough thatpatchcan follow them without you.
2. Where the Name Comes From
diff is short for difference, and unusually for a Unix command that is the whole story. The interesting names are the ones around it:
diff the DIFFerence between two files
patch applies a diff to a file; named for what it does
hunk one contiguous group of changed lines, with its surrounding context
context the unchanged lines printed around a change, so patch can find the place
fuzz how much of that context patch is allowed to ignore (section 7.2)
.rej a rejected hunk, written to a file for you to deal with by hand
.orig the file as it was before patch touched it
The word hunk is the one worth internalising, because every tool in this family reports in hunks: diff produces them, patch applies or rejects them one at a time, and git lets you stage them individually.
A hunk is a change plus its neighbourhood. That neighbourhood is the whole trick: by including a few unchanged lines above and below, the patch can still be applied after the file has moved around, because patch searches for the context rather than trusting the line numbers.
3. A Short History
diff came out of Bell Labs in the mid-1970s, and the problem it solved was not source control. It was that computers were slow and disks were small, and storing the difference between two versions of a file was far cheaper than storing both.
The part of the story that changed how software is written came later. In 1985 Larry Wall released patch, and the combination of the two turned a diff from a description into a delivery mechanism. Software was distributed by posting diffs to Usenet newsgroups, where anyone could apply them to their own copy of the source.
| Era | Milestone |
|---|---|
| Mid 1970s | diff appears in Unix at Bell Labs, with the algorithm published by Hunt and McIlroy |
| 1985 | Larry Wall releases patch, and code starts travelling by mail and newsgroup |
| Around 1990 | The unified format arrives, compact enough to read and to quote in a message |
| 2005 onwards | Git and its contemporaries build on the same format instead of replacing it |
| Today | Every code review you have ever seen is a unified diff with a web page around it |
The manual page for patch still credits the people involved, and one name in it explains the format you read every day:
$ man patch
AUTHORS
Larry Wall wrote the original version of patch. Paul Eggert removed
patch's arbitrary limits; added support for binary files, setting
file times, and deleting files; ... Other contributors include
Wayne Davison, who added unidiff support, and David MacKenzie, who
added configuration and backup support.
"Unidiff support" is the unified format. It is worth appreciating how completely it won: GitHub, GitLab, every code review tool, and git diff itself all print the format that was added to a 1985 program so that patches would fit in an email.
Back to topLearning to read a unified diff is not learning a Unix command. It is learning the format that every code review in the world is written in.
4. Simple Use Cases
4.1 The Format You Actually Want: -u
The -u flag (short for unified) prints one block of text with the removals and additions interleaved, instead of two separate lists:
$ diff -u old.php new.php
--- old.php 2026-08-23 17:15:36.275783415 +0200
+++ new.php 2026-08-23 17:15:36.276783411 +0200
@@ -1,7 +1,7 @@
<?php
$config = [
- 'host' => 'localhost',
+ 'host' => 'db.internal',
'user' => 'joomla',
- 'debug' => false,
+ 'debug' => true,
];
echo "ready\n";
Read it as one file with two overlaid versions:
| Line starts with | Means |
|---|---|
--- |
The original file, with its timestamp |
+++ |
The new file |
@@ |
The start of a hunk (explained in 4.2) |
| A space | Context: unchanged, present in both |
- |
Removed from the original |
+ |
Added in the new version |
A changed line always appears twice, once as - and once as +. There is no "modified" marker, because at this level there is no such thing as modifying a line: you delete the old one and add a new one.
There is an older -c (context) format that prints the two versions in separate blocks with ! marking changes. You will meet it in old documentation. Unified says the same thing in roughly half the space, which is why it took over.
4.2 Reading the @@ Line
This is the part everyone skips, and it takes thirty seconds to learn. Here is a file with three separate changes:
$ diff -u big-old.txt big-new.txt
@@ -1,6 +1,6 @@
line 1
line 2
-line 3
+line 3 CHANGED
line 4
line 5
line 6
@@ -8,6 +8,7 @@
line 8
line 9
line 10
+line 10.5 INSERTED
line 11
line 12
line 13
@@ -22,7 +23,7 @@
line 22
line 23
line 24
-line 25
+line 25 CHANGED
line 26
Each @@ line says where the hunk sits in both files:
@@ -22,7 +23,7 @@
| | | |
| | | └─ ... and covers 7 lines there
| | └─── in the NEW file it starts at line 23 ...
| └───── ... and covers 7 lines
└─────── in the OLD file this hunk starts at line 22 ...
Now look at the three hunks together. The first starts at line 1 in both. The second starts at line 8 in both, but covers 6 lines in the old file and 7 in the new, because a line was inserted. By the third hunk the files have drifted apart: it starts at line 22 in the old file and line 23 in the new one. That single number is the running total of everything added and removed above it.
You can change how much context is included, which changes how the hunks group:
$ diff -U1 big-old.txt big-new.txt | grep -c '^@@'
3
$ diff -U5 big-old.txt big-new.txt | grep -c '^@@'
2
With five lines of context, two of the changes are close enough that their neighbourhoods overlap, so diff merges them into one hunk. The default is three, which is a good compromise and the reason almost every patch you see has exactly three unchanged lines around each change.
4.3 Side by Side
For reading rather than for patching, -y puts the two files in columns:
$ diff -y --width=68 old.php new.php
<?php <?php
$config = [ $config = [
'host' => 'localhost', | 'host' => 'db.internal',
'user' => 'joomla', 'user' => 'joomla',
'debug' => false, | 'debug' => true,
]; ];
echo "ready\n"; echo "ready\n";
The marker in the middle column tells you what happened: | changed, < only in the left file, > only in the right. On a long file, add --suppress-common-lines so you only see the changes:
$ diff -y --suppress-common-lines --width=60 old.php new.php
'host' => 'localhost', | 'host' => 'db.internal'
'debug' => false, | 'debug' => true,
4.4 Colour
GNU diff can colour its own output, which makes a unified diff far easier to scan:
$ diff --color=auto -u old.php new.php
auto is the value you want: colour when the output is a terminal, and plain text when it is piped or redirected. That matters, because a patch file with colour codes in it is not a patch file any more. --color=always exists for piping into a pager, and it is the wrong choice everywhere else.
An alias makes it permanent:
alias diff='diff --color=auto -u'
Back to top5. Moderate Use Cases
5.1 Comparing Two Directories
This is where diff stops being a programmer's tool and becomes an administrator's one. -r (short for recursive) walks two trees:
$ diff -r v1 v2
Only in v2: added.txt
diff -r v1/changed.txt v2/changed.txt
1c1
< old
---
> new
diff -r v1/inc/deep.txt v2/inc/deep.txt
1c1
< x
---
> y
Only in v1: removed.txt
On two real websites that output is thousands of lines long. Almost always what you want is -q as well (short for brief), which reports which files differ without printing their contents:
$ diff -rq v1 v2
Only in v2: added.txt
Files v1/changed.txt and v2/changed.txt differ
Files v1/inc/deep.txt and v2/inc/deep.txt differ
Only in v1: removed.txt
diff -rq is the single most useful command in this article for anyone who runs servers. It answers "what is different between these two copies of the site?" in one line, and it is the first thing to run when a deployment has gone wrong, when you suspect a core file has been modified, or when you want to know what an update actually changed.
One option changes its meaning. -N treats a missing file as an empty one, so "only in" becomes "differs":
$ diff -rqN v1 v2
Files v1/added.txt and v2/added.txt differ
Files v1/changed.txt and v2/changed.txt differ
Files v1/inc/deep.txt and v2/inc/deep.txt differ
Files v1/removed.txt and v2/removed.txt differ
Use -N when you are generating a patch, because a patch must be able to create and delete files. Leave it off when you are reading the output yourself, because "only in" is more informative than "differs".
Before you trust any of this, read section 7.1. diff -rq compares file contents and nothing else, and what it leaves out matters more than most people expect.
5.2 Excluding Files and Directories
On a real site the previous command drowns in noise. Caches, logs and generated thumbnails differ constantly and tell you nothing:
$ diff -rq site-a site-b
Files site-a/cache/x.tmp and site-b/cache/x.tmp differ
Files site-a/index.php and site-b/index.php differ
Files site-a/logs/error.log and site-b/logs/error.log differ
Only the middle line matters. -x (short for exclude) takes a shell-style pattern and can be repeated:
$ diff -rq -x cache -x '*.log' site-a site-b
Files site-a/index.php and site-b/index.php differ
Once the list grows, put it in a file and pass it with -X. That file belongs in version control next to whatever script runs the comparison:
$ cat skip.txt
cache
logs
tmp
*.log
$ diff -rq -X skip.txt site-a site-b
Files site-a/index.php and site-b/index.php differ
One rule catches everybody once: the pattern is matched against the file name, not the path. Writing a path with a slash in it silently matches nothing:
$ diff -rq -x 'logs/*' site-a site-b
Files site-a/cache/x.tmp and site-b/cache/x.tmp differ
Files site-a/index.php and site-b/index.php differ
Files site-a/logs/error.log and site-b/logs/error.log differ <-- not excluded
Exclude logs, not logs/* and not site-a/logs. The name on its own is what diff compares each entry against as it walks the tree, and excluding a directory by name skips everything inside it.
5.3 Ignoring What You Do Not Care About
By default every byte counts, including whitespace:
$ diff ws1.txt ws2.txt
1,2c1,2
< hello world
< second line
---
> hello world
> second line
One line gained a space in the middle, the other gained trailing spaces, and both are reported. Four flags turn that off, from mildest to broadest:
| Flag | Ignores |
|---|---|
-b |
Changes in the amount of whitespace |
-w |
All whitespace, everywhere |
-B |
Lines that are entirely blank |
-i |
Upper and lower case |
There is also -I, which takes a regular expression and ignores any change on a line matching it. This is how you compare two generated reports without the timestamp at the top making every file look different:
$ diff r1.txt r2.txt
1c1
< Generated: 2026-08-23
---
> Generated: 2026-08-24
$ echo $?
1
$ diff -I '^Generated:' r1.txt r2.txt
$ echo $?
0
Use these to investigate, not to decide. A whitespace change is invisible to -w and highly visible to Python, YAML, and anything that reads a here-document.
5.4 The Line-Ending Trap
Every so often a file that "nobody touched" shows every single line as changed. This is almost always line endings, and it is the most common false alarm in web work, because a file edited on Windows and uploaded by FTP comes back with a carriage return on every line.
$ diff crlf.txt lf.txt | cat -A
1,2c1,2$
< line one^M$
< line two^M$
---$
> line one$
> line two$
The ^M at the end of each line is the carriage return, made visible by cat -A. Without that trick the two sides look identical and the diff looks insane. Two commands confirm it and one flag ignores it:
$ file crlf.txt lf.txt
crlf.txt: ASCII text, with CRLF line terminators
lf.txt: ASCII text
$ diff --strip-trailing-cr crlf.txt lf.txt ; echo $?
0
Fixing the file properly is better than ignoring the difference forever. dos2unix does it, and so does sed -i 's/\r$//' file on a machine that does not have it.
5.5 When It Is Not Text
diff gives up on binary files, and says so:
$ diff bin1.dat bin2.dat
Binary files bin1.dat and bin2.dat differ
That is usually all you need, and the exit code still works. When you want detail, cmp is the right tool: it compares byte by byte and tells you where the first difference is.
$ cmp bin1.dat bin2.dat
bin1.dat bin2.dat differ: byte 25, line 1
$ cmp -l bin1.dat bin2.dat | head -3
25 60 300
26 155 72
41 50 30
cmp -l lists every differing byte with its position and the two values in octal. For a plain yes-or-no on large files, cmp -s (silent) and diff -q are equally quick, because both stop at the first difference; on a pair of identical 200 MB files here they took 0.059s and 0.062s. Choose cmp when you want to know where the difference is, and diff when you want to know what it says.
When you need to actually look at the bytes, turn them into text first and diff that. xxd produces a hex dump with one line per sixteen bytes, which is exactly the shape a line-based tool wants:
$ diff -u <(xxd h1.bin) <(xxd h2.bin)
@@ -1,4 +1,4 @@
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
-00000010: 0300 3e00 0100 0000 306d 0000 0000 0000 ..>.....0m......
-00000020: 4000 0000 0000 0000 2824 0200 0000 0000 @.......($......
+00000010: 0300 3e00 0100 0000 c03a 0000 0000 0000 ..>......:......
+00000020: 4000 0000 0000 0000 1892 0000 0000 0000 @...............
This does not make binary formats readable, but it turns "these two files differ somewhere" into "these two files differ at offset 0x10 and 0x20", which is often enough to recognise a version number, a timestamp or a header field.
5.6 Comparing Things That Are Not Files
Bash lets you hand the output of a command to anything expecting a filename, using <(...). This removes the temporary files from a whole category of checks:
$ diff <(printf 'apache2\nmysql\nphp\n') <(printf 'apache2\nnginx\nphp\n')
2c2
< mysql
---
> nginx
The same idea answers real questions about two servers:
$ diff <(ssh web1 'dpkg -l | sort') <(ssh web2 'dpkg -l | sort')
$ diff <(ssh web1 'php -m | sort') <(ssh web2 'php -m | sort')
$ diff <(cd /var/www/live && find . | sort) <(cd /var/www/staging && find . | sort)
Sort both sides. diff compares in order, so two identical package lists in a different order produce pages of noise. That one habit is what makes this technique usable.
There is one rough edge. Because the "files" are anonymous pipes, the header names them after file descriptors, which tells you nothing:
$ diff -u <(echo a) <(echo b) | head -2
--- /dev/fd/63 2026-08-23 18:33:16.048005790 +0200
+++ /dev/fd/62 2026-08-23 18:33:16.048005790 +0200
--label replaces them, once per side, in order:
$ diff -u --label production --label staging <(echo a) <(echo b) | head -2
--- production
+++ staging
Use it whenever the output will be read by someone else or pasted into a ticket. A diff whose two sides are called production and staging needs no explanation; one whose sides are called /dev/fd/63 and /dev/fd/62 needs a paragraph.
5.7 Normalise Before You Compare
The previous section ended on a habit worth stating as a rule: the best comparison is usually not a comparison of the raw input. Two sides can hold the same information in a different order, a different layout or a different encoding, and a line-based tool will report all of it as change.
The pattern is always the same:
raw input --> normalise --> compare
Sort, and pin the locale. Sorting both sides is the obvious half. The half almost nobody mentions is that sort order itself depends on the locale, so the same data sorted on two machines with different language settings does not match:
$ cat loc.txt
apple
Banana
cherry
$ LC_ALL=en_US.UTF-8 sort loc.txt
apple Banana cherry
$ LC_ALL=C sort loc.txt
Banana apple cherry <-- capitals first, by byte value
Diff those two orderings against each other and you get differences on every line, from identical input. When the two sides come from two different servers, that is exactly what happens. Pin the locale on both sides and the problem disappears:
$ diff <(LC_ALL=C sort list1.txt) <(LC_ALL=C sort list2.txt)
$ diff <(ssh web1 'LC_ALL=C dpkg-query -W | sort') \
<(ssh web2 'LC_ALL=C dpkg-query -W | sort')
LC_ALL=C gives a plain byte ordering that is the same everywhere. It is not the ordering a human would choose, and that does not matter, because nobody is reading it: the two sides only have to agree.
Use a format-aware tool where one exists. Reformatted JSON is a total rewrite as far as diff is concerned, even when nothing changed. jq -S reprints it with the keys sorted, so only real differences survive:
$ diff -u --label old --label new <(jq -S . old.json) <(jq -S . new.json)
The same idea covers most "why is everything different?" complaints: pretty-print the XML, sort the database dump, strip the timestamp header with -I, canonicalise the line endings. Do the normalising once, in the command, and the diff becomes readable.
6. Advanced Use Cases
6.1 Making a Patch
A patch is just a unified diff saved to a file. Two rules make the difference between one that applies and one that does not.
Use -u, and include the directory name. Run the comparison from one level above the tree, so the paths in the patch carry a prefix that can be stripped:
$ diff -u site-a/components/helper.php site-b/components/helper.php > fix.patch
$ cat fix.patch
--- site-a/components/helper.php 2026-08-23 17:16:58.021471057 +0200
+++ site-b/components/helper.php 2026-08-23 17:16:58.022471053 +0200
@@ -1,5 +1,5 @@
<?php
function getLimit()
{
- return 20;
+ return 50;
}
For a whole tree, add -r and -N so that new and deleted files are included:
$ diff -ruN site-a site-b > release.patch
6.2 Applying It: the -p Levels
This is where most patch attempts fail, and the reason is simple once you see it. The patch contains a path. patch has to turn that path into a file on your disk, and -p tells it how many leading directory components to throw away.
Path in the patch: site-a/components/helper.php
-p0 looks for site-a/components/helper.php
-p1 looks for components/helper.php
-p2 looks for helper.php
So the right value depends entirely on where you are standing. From inside a copy of the site, the site-a/ prefix is not on disk and must be stripped:
$ cd /var/www/site
$ patch -p0 < ../fix.patch
can't find file to patch at input line 3
Perhaps you used the wrong -p or --strip option?
$ patch -p1 < ../fix.patch
patching file components/helper.php
-p1 is the usual answer, because patches are conventionally made one level above the tree, and it is what git produces and expects. When in doubt, read the --- line of the patch and count.
6.3 Dry Runs, Backups and Undo
Three habits turn patch from something you hope works into something you can control.
Try it first. --dry-run does everything except write:
$ patch -p1 --dry-run < ../fix.patch
checking file components/helper.php
$ echo $?
0
Note the wording: "checking file" on a dry run, "patching file" when it is real. That is how you tell from a log which one happened.
Keep the original. By default patch keeps nothing. -b saves the previous version next to the file:
$ patch -p1 -b < ../fix.patch
$ ls -A components/
helper.php helper.php.orig
Know how to undo it. A patch is reversible, because it describes both sides of every change. -R runs it backwards:
$ patch -p1 -R < ../fix.patch
patching file components/helper.php
That is the real answer to "the fix made things worse". You do not need the old file; you need the patch that produced the new one.
6.4 When It Does Not Apply Cleanly
Real files drift. patch is built for that, and it tells you exactly how hard it had to work.
Offset means it found the context somewhere else in the file. Here two comment lines had been added at the top:
$ patch -p1 < ../fix.patch
patching file components/helper.php
Hunk #1 succeeded at 3 with fuzz 1 (offset 2 lines).
$ echo $?
0
An offset is normal and harmless. patch searched outwards from the line number in the hunk header, found the surrounding lines two lines lower, and applied the change there. This is exactly why the context lines exist.
Rejection means it gave up. Applying the same patch twice is the usual cause:
$ patch -p1 < ../fix.patch
patching file components/helper.php
Reversed (or previously applied) patch detected! Assume -R? [n]
Apply anyway? [n]
Skipping patch.
1 out of 1 hunk ignored -- saving rejects to file components/helper.php.rej
$ echo $?
1
Two things to notice. patch recognised that the file already contained the result and offered to reverse it, which is a genuinely clever piece of engineering from 1985. And it wrote a .rej file containing the hunks it could not place:
$ cat components/helper.php.rej
--- components/helper.php
+++ components/helper.php
@@ -1,5 +1,5 @@
<?php
function getLimit()
{
- return 20;
+ return 50;
}
A .rej file is not an error message. It is the work that is left for you to do by hand. After any patch that reports rejects, search the tree for them before you declare the job finished:
$ find . -name '*.rej' -o -name '*.orig'
Between "applied cleanly" and "rejected" there is a third outcome that deserves its own section, because it succeeds and it should worry you. Section 7.2.
6.5 Three-Way, and Where Git's Conflict Markers Came From
diff3 compares three files: yours, theirs, and the common ancestor both started from. That is the shape of every merge.
$ diff3 -m mine.txt base.txt theirs.txt
a
<<<<<<< mine.txt
MINE
||||||| base.txt
BASE
=======
THEIRS
>>>>>>> theirs.txt
c
Anyone who has used git will recognise that output immediately. Those markers are not a git invention: git shells out to the same three-way merge logic and prints the same format. The middle section, between ||||||| and =======, is what the line looked like before either side touched it, and it is the piece people most often wish they had when resolving a conflict.
The argument order is worth memorising, because it is not alphabetical and it is not obvious: mine, base, theirs. The common ancestor goes in the middle.
6.6 Applying a Patch You Did Not Write
patch writes files, so a patch from an unknown source is untrusted input. The traditional worry is that a patch could contain a path pointing outside your tree. On GNU patch 2.7.6 that is handled, and it is worth seeing the refusals rather than taking it on faith.
A patch naming an absolute path is rejected by name:
$ patch -p0 < ../abs.patch
Ignoring potentially dangerous file name /home/peter/sec/outside.txt
can't find file to patch at input line 3
$ cat ../outside.txt
ORIGINAL <-- untouched
A patch using ../ to climb out is refused too, at both -p0 and -p1:
$ patch -p1 < ../evil.patch
can't find file to patch at input line 3
Perhaps you used the wrong -p or --strip option?
$ cat ../outside.txt
ORIGINAL <-- still untouched
So the escape routes are closed. What is not closed is everything the patch is legitimately allowed to do inside your tree, which is the part that actually matters:
- Read it first. A patch is plain text.
less fix.patchshows you every line it will change, which is more than can be said for most things you install. - Check which files it touches before you look at the contents:
grep '^+++' fix.patch. - Dry run, then apply.
patch -p1 --dry-runcosts nothing. - Work on a copy if the tree is production, and diff the copy back afterwards.
That last one is the general rule this whole article keeps arriving at: the tool that applies a change and the tool that verifies it are the same tool, pointed in opposite directions.
Back to top7. Something Most Users Do Not Know
7.1 diff -rq Compares Content and Nothing Else
Section 5.1 called diff -rq the most useful command in this article. It is, and it will also tell you that two directories match when they are meaningfully different, because it compares file contents and nothing else.
Here are two directories. One file has the same contents in both, but its permissions and its timestamp differ:
$ ls -l p1/f.txt p2/f.txt
-rw-r--r-- Jan 1 2020 p1/f.txt
-rwxrwxrwx Aug 23 17:16 p2/f.txt
$ diff -rq p1 p2
$ echo $?
0 <-- "identical"
A file went from 644 to world-writable 777 and diff reported nothing at all. On a website that is the difference between a hardened file and one anybody can rewrite, and a restore verified this way passes with the permissions wrong.
Symbolic links are worse, because diff follows them. Here s1/ptr is a symlink and s2/ptr is a real file with the same content:
$ ls -l s1/ptr s2/ptr
lrwxrwxrwx s1/ptr -> real.txt
-rw-rw-r-- s2/ptr
$ diff -rq s1 s2
$ echo $?
0 <-- still "identical"
The structure was destroyed and the check passed. For any site using the standard current -> releases/2026-08 deployment layout, that is exactly the failure that would matter, and exactly the one this command hides.
Modern GNU diff has a flag for it:
$ diff -rq --no-dereference s1 s2
File s1/ptr is a symbolic link while file s2/ptr is a regular file
$ echo $?
1
So the honest summary of what diff -rq checks is short:
| Property | Checked by diff -rq? |
|---|---|
| File contents | Yes. This is the whole job. |
| Files present or missing | Yes, reported as "Only in" |
| Permissions | No |
| Owner and group | No |
| Timestamps | No |
| Symlink versus real file | No, unless you add --no-dereference |
| Empty directories | Reported as "Only in", but never as a difference in content |
Section 7.5 gives the command that covers the rest.
7.2 Fuzz Will Apply Your Patch to Code That Moved On
Section 6.4 showed a patch failing loudly. This is the failure that does not.
patch finds a hunk by matching its context lines. When it cannot match all of them, it does not stop: it tries again while ignoring the outermost lines of context. The manual states the limit plainly:
$ man patch
First patch looks for a place where all lines of the context match.
If no such place is found, and it's a context diff, and the maximum
fuzz factor is set to 1 or more, then another scan takes place
ignoring the first and last line of context. If that fails, and the
maximum fuzz factor is set to 2 or more, the first two and last two
lines of context are ignored, and another scan is made.
(The default maximum fuzz factor is 2.)
Watch what that permits. Here the function the patch was written against has been renamed by somebody else, so the context no longer matches:
$ patch -p1 < ../fix.patch
patching file components/helper.php
Hunk #1 succeeded at 1 with fuzz 2.
$ echo $?
0
Exit code 0. A script sees success. But patch has just written a change into a file whose surrounding code is not the code the patch author was looking at, because it threw away two lines of context at each end in order to find a match.
Sometimes that is exactly right and it saves you an afternoon. Sometimes it puts a fix in a function that is no longer the function it was meant for. The point is that you were not asked.
patchsucceeding is not the same aspatchbeing correct. Any line that mentions fuzz means the file had drifted and patch guessed; read what it did before you move on.
For anything unattended, turn the guessing off. -F0 sets the maximum fuzz to zero, so a hunk either matches exactly or fails:
$ patch -p1 -F0 < ../fix.patch
patching file components/helper.php
Hunk #1 FAILED at 1.
1 out of 1 hunk FAILED -- saving rejects to file components/helper.php.rej
$ echo $?
1
That is the behaviour you want in a deployment script: a clear failure you can act on, rather than a silent success you have to discover later.
7.3 diff Does Not See a Moved Block
diff is not comparing line 1 with line 1 and line 2 with line 2. If it were, inserting a single line at the top of a file would report every line below it as changed. Instead it looks for the longest run of lines the two files have in common and treats everything else as inserted or deleted, which is why a one-line insertion reports as exactly one added line.
That alignment is what makes diff useful, and it has a blind spot: it has no concept of a move. Take a file and swap the order of two functions, changing nothing inside them:
$ diff -u mv1.txt mv2.txt
@@ -1,6 +1,6 @@
header
-function A
-body A
function B
body B
+function A
+body A
footer
Nothing was added and nothing was removed, but the diff shows two deletions and two additions. To the tool there is no difference between "this block moved" and "this block was deleted here and an identical one was written there".
This matters in review. A patch that reorders a large file looks enormous and reads as though everything was rewritten, and the genuinely new lines are buried among the moved ones. Three ways out, in order of how much you have to install:
- Do not mix moves with edits. If you are going to reorder a file, do it in one change that only reorders, so the next diff is readable.
- Ask for a different alignment.
-d(--minimal) tellsdiffto "try hard to find a smaller set of changes". It is slower, and on straightforward files it usually returns the same answer, but on a badly aligned diff it is worth one attempt. - Use a tool that tracks moves when reviewing, such as
git diff --color-moved, which paints moved blocks in a different colour instead of pretending they are new.
The wider point is that a diff is one valid description of how to get from A to B, not the only one and not necessarily the one a human would have written. Two tools can produce different-looking output for the same pair of files and both be correct.
7.4 A Diff Is a Program, Not a Report
Everything else in this article follows from one idea that is easy to miss: the output of diff is machine-readable by design, and the machine that reads it is patch.
That is why the format is so fussy. The leading space on context lines is not decoration, it is the marker for "unchanged" and removing it breaks the patch. The line counts in the @@ header must match the number of lines that follow. An editor that strips trailing whitespace can invalidate a patch file. So can a mail client that wraps long lines, which is why patches were traditionally sent as attachments and why every project has a page about how not to mangle them.
It also explains the two rules that surprise people:
- Never edit a patch file to "fix" it unless you are prepared to fix the line counts in every hunk header you touch.
- Never let colour into a patch.
--color=always > fix.patchproduces a file that looks perfect and thatpatchcannot read, because every line now starts with an escape sequence rather than with a space, a plus or a minus.
7.5 Verifying a Restored Backup Properly
Put 7.1 together with the rest and you get the check that is actually worth running after a restore or a migration. It has two halves, because no single command covers both.
First, contents:
$ diff -rq --no-dereference /var/www/live /mnt/restore/live
Then everything diff ignores, by turning the metadata into text and diffing that instead:
$ diff \
<(cd /var/www/live && find . -printf '%p %m %u:%g\n' | sort) \
<(cd /mnt/restore/live && find . -printf '%p %m %u:%g\n' | sort)
3c3
< ./f.txt 644 www-data:www-data
---
> ./f.txt 777 www-data:www-data
The find -printf format string is doing the work: %p the path, %m the permission bits in octal, %u:%g the owner and group. Sorting both sides makes the comparison stable. The result is a one-line report of every file whose mode or ownership changed, which is precisely what the content comparison cannot see.
The same pair of commands answers a second question that comes up more often than anyone would like: has anything on this site been modified that should not have been? Download a clean copy of the same version, and compare it against the running site. Every file that differs is either your own customisation, or something you need to look at very carefully.
Generalise it once more and you have the most useful habit in this article. Capture the state as text before you change anything, capture it again afterwards, and diff the two. It costs two commands and it answers "what did that actually do?" precisely:
$ before=$(mktemp) after=$(mktemp)
$ trap 'rm -f "$before" "$after"' EXIT
$ dpkg-query -W | LC_ALL=C sort > "$before"
$ sudo apt upgrade
$ dpkg-query -W | LC_ALL=C sort > "$after"
$ diff -u --label before --label after "$before" "$after"
The trap line is what makes it safe to put in a script: the temporary files are removed even if it exits early. Anything that prints text works as a snapshot, and on Linux that is nearly everything:
find /etc -type f | LC_ALL=C sort which configuration files exist
systemctl list-unit-files --state=enabled which services will start
ss -tulpn which ports are listening
php -m | LC_ALL=C sort which PHP extensions are loaded
crontab -l ; ls /etc/cron.d what is scheduled
Run one of those before a migration and again afterwards, and the differences between the two servers stop being a matter of memory.
7.6 Knowing Where diff Stops
Part of expertise is knowing when a tool is the wrong one.
| Need | Use | Why |
|---|---|---|
| Compare structured data | jq -S then diff, or a format-aware tool |
Reformatted JSON is a total rewrite to a line-based tool |
| Compare word by word | git diff --word-diff, wdiff |
diff has no concept smaller than a line |
| Track changes over time | git |
diff compares two states; it remembers nothing |
| Compare a database | A dump, sorted, then diff | Row order is not guaranteed, so the diff is meaningless without sorting |
| Sync rather than report | rsync -n |
A dry-run rsync lists what would change, and can then do it |
| Compare two sorted lists | comm |
Gives you three columns: only-in-A, only-in-B, and both |
The last row is underused. When you have two lists of names, comm -13 and comm -23 answer "what is only on this side?" more directly than reading a diff does.
8. Best Practices
- Default to
diff -u. It is compact, it is what every code review tool shows you, and it is the only formatpatchandgitboth take without argument. Alias it if you have to. - Learn the
@@line once. Old start and length, new start and length. Thirty seconds of reading turns every patch and every pull request from a wall of symbols into a sentence. - Use
diff -rqas your first move on any "what changed?" question, and add--no-dereferenceso symlinks are compared as symlinks. - Exclude the noise with
-xor-X, and remember the pattern is matched against names, not paths. - Check metadata separately. Contents are only half of a restore. The
find -printfcomparison in section 7.5 catches the permission and ownership changes thatdiffis blind to. - Sort both sides, and pin the locale. Package lists, module lists and directory listings come back in whatever order the system felt like, and the order itself depends on
LANG.LC_ALL=C sorton both sides. - Test for exit code 2, not just for "non-zero". 0 is identical, 1 is different, 2 is that
diffitself failed. Treating a typo in a path as "the files differ" produces confident nonsense. - Always dry-run a patch.
patch -p1 --dry-runcosts a second and tells you whether the-plevel is right before anything is written. - Use
-bwhen patching by hand, so the previous version is sitting next to the file if you need it. - Read every line that mentions fuzz. An offset is fine. Fuzz means the file had drifted and
patchguessed, and it still exits 0. - Set
-F0in anything automated. A failed hunk you can see beats a fuzzy hunk you cannot. - Hunt for
.rejand.origbefore declaring victory.find . -name '*.rej'is the last step of applying any patch that was not perfectly clean. - Use it as a test.
your-command > actual.txtthendiff -u expected.txt actual.txtis a complete regression test: exit 0 passes, exit 1 fails, and the failure output is already a readable explanation of what changed. Most snapshot-testing frameworks are this with extra steps. - Never redirect coloured output into a patch file. Use
--color=autoand nothing else.
$ man 1 diff # every option, grouped by what it ignores
$ man 1 patch # the -p levels, fuzz, rejects and backups
$ info diff # the full GNU manual, much deeper than the man page
$ man 1 cmp # byte-level comparison
$ man 1 diff3 # three-way, and the origin of conflict markers
Back to top9. Common Mistakes
9.1 Myth Versus Reality
| Myth | Reality |
|---|---|
"diff -rq proved the restore is correct." |
It proved the file contents match. Permissions, ownership, timestamps and symlink structure are all unchecked. |
"patch exited 0, so the patch applied correctly." |
It applied. With fuzz it may have applied to code that no longer matches the patch's context. |
| "A non-zero exit means the files differ." | 1 means they differ. 2 means diff failed, usually a wrong path. |
| "The whole file changed, so somebody rewrote it." | Far more often it is line endings. Check with file and cat -A before accusing anyone. |
"-p1 is a magic number." |
It is the count of leading path components to strip. Read the --- line and work out where you are standing. |
"A .rej file means the patch failed." |
It means those hunks failed. The rest were applied, so the tree is now half-patched. |
| "diff shows what changed." | It shows one way to get from A to B. Move a block of code and diff reports it as a deletion plus an unrelated addition (section 7.3). |
| "I sorted both sides, so the comparison is fair." | Sort order depends on the locale. The same list sorts differently under en_US.UTF-8 and C, so two servers can disagree on identical data. |
"-x 'cache/*' excludes the cache directory." |
Exclusion patterns match the file name, not the path. Write -x cache; anything with a slash in it matches nothing. |
| "Unified diff is a git thing." | It was added to Larry Wall's patch around 1990, fifteen years before git existed. |
9.2 Other Traps to Avoid
- Comparing unsorted output.
diff <(ssh a 'dpkg -l') <(ssh b 'dpkg -l')withoutsorton both sides produces a diff of the ordering, not of the content. - Reversing the argument order.
diff new oldis a valid command that produces a patch which undoes your work. The first file is always the "from". - Forgetting
-Nwhen generating a tree patch. Without it, files that only exist on one side are mentioned as "Only in" and are not included, so the patch silently cannot create them. - Applying a patch from the wrong directory. The
-plevel and your working directory are two halves of the same setting. Get one wrong and you get "can't find file to patch", or worse, you patch a file with the same name somewhere else. - Using
-wto make a difference go away. Whitespace is significant in Python, YAML, Makefiles and here-documents.-wis for investigating, not for concluding. - Editing a patch file by hand. The line counts in the
@@headers must agree with the lines that follow, and nothing warns you when they do not. - Trusting a diff of a generated file. Minified assets, compiled templates and cache files differ constantly for reasons nobody needs to see. Exclude them, or compare the sources they came from.
- Writing an exclusion as a path.
-x 'logs/*'and-x 'site/cache'both match nothing at all, silently. The pattern is tested against each entry's name on its own. - Comparing two servers without pinning the locale. If
LANGdiffers between them,sortproduces a different order on each side and the diff is entirely noise.LC_ALL=C sorton both sides. - Leaving
.origfiles in a web root.helper.php.origis not executed by PHP, which means the web server may serve it as plain text, including whatever is in it. Clean them up.
10. Summary
diff is fifty years old, its output format is the language every code review is written in, and its most valuable use on a server has nothing to do with programming.
diffdescribes how to turn the first file into the second. Order matters, and the output is meant to be read bypatchas much as by you.- Exit codes carry the meaning: 0 identical, 1 different, 2 something failed.
- Use
-u. The@@ -22,7 +23,7 @@header is old start and count, new start and count, and the drift between the two numbers is everything added or removed above. diff -rqanswers "what is different between these two copies of the site?" in one line, and--no-dereferencemakes it honest about symlinks.- Exclude the noise with
-xor-X, and remember the pattern matches names, not paths. - Normalise before you compare: sort both sides, pin the locale with
LC_ALL=C, and usejq -Sor an equivalent for structured data. - diff has no concept of a moved block. Reordering a file reads as a wholesale rewrite, which is worth knowing before you review one.
- Whitespace and line endings are the two most common false alarms.
fileandcat -Aidentify them;--strip-trailing-crand-bwork around them; fixing the file is better. - A patch is a unified diff in a file.
-ptellspatchhow many leading directories to strip, and it depends on where you are standing. --dry-runfirst,-bto keep the original,-Rto undo. A patch is reversible by design.- An offset is normal. Fuzz is a warning: the file had drifted,
patchignored some context to make it fit, and it still exits 0. Use-F0where nobody is watching. - Git's conflict markers come from
diff3, and the middle section is the common ancestor. - Modern
patchrefuses absolute paths and../escapes, so the real risk is what a patch legitimately does inside your tree. Read it; it is plain text. diff -rqchecks contents only. Permissions, ownership and symlink structure need thefind -printfcomparison.
This is the quick reference worth keeping:
diff -u a b unified: the format everything else speaks
diff -y a b side by side, add --suppress-common-lines
diff --color=auto -u a b colour on screen, plain when redirected
diff -rq DIR1 DIR2 WHICH files differ (the one to remember)
diff -rq --no-dereference ... ... and treat symlinks as symlinks
diff -rqN DIR1 DIR2 count missing files as differences too
diff -rq -x cache -x '*.log' exclude by NAME (never a path)
diff -rq -X skip.txt DIR1 DIR2 exclusion patterns from a file
-b ignore whitespace amount -w ignore all whitespace
-B ignore blank lines -i ignore case
-I REGEX ignore matching lines (timestamps, version stamps)
--strip-trailing-cr ignore Windows line endings
-U N N lines of context (default 3)
exit 0 identical | 1 different | 2 diff itself failed
diff -u old new > fix.patch make a patch
diff -ruN old/ new/ > release.patch make a whole-tree patch
patch -p1 --dry-run < fix.patch try it, change nothing
patch -p1 -b < fix.patch apply, keeping .orig
patch -p1 -R < fix.patch undo it
patch -p1 -F0 < fix.patch no fuzz: exact match or fail
grep '^+++' fix.patch which files does it touch?
find . -name '*.rej' -o -name '*.orig' ALWAYS, after patching
cmp a b first differing byte
comm -23 a b lines only in the first (sorted input)
diff3 -m mine base theirs three-way merge with conflict markers
diff <(cmd1 | sort) <(cmd2 | sort) compare output, not files
diff -u --label old --label new ... name the sides (pipes are /dev/fd/63)
diff <(LC_ALL=C sort a) <(LC_ALL=C sort b) same order on every machine
diff -u <(jq -S . a.json) <(jq -S . b.json) ignore key order
diff -u <(xxd a.bin) <(xxd b.bin) byte differences, readably
# before and after: what did that change actually do?
cmd | LC_ALL=C sort > before ; ...do the thing... ; cmd | LC_ALL=C sort > after
diff -u --label before --label after before after
# verify a restore: contents, then everything diff ignores
diff -rq --no-dereference /var/www/live /mnt/restore/live
diff <(cd /var/www/live && find . -printf '%p %m %u:%g\n' | sort) \
<(cd /mnt/restore/live && find . -printf '%p %m %u:%g\n' | sort)
The gap between "the backup ran" and "the backup restores" is where most unpleasant surprises live, and comparing what came back against what was there is the cheapest way to close it.
Back to top

Peter is a Joomla specialist and a Linux admin for fast, secure and scalable websites.












