2026-06-28

Notes on the Unified Diff

Last year, I worked on some projects which involved parsing and processing the unified diff format. While I’d say the unified diff format is pretty easy to read, the actual spec of the format has always been a little bit blurry to me. These are some notes I took on the format that I’m uploading to hit my mental one blog post a year quotalmao.

Unified diff

--- lao	2002-02-21 23:30:39.942229878 -0800
+++ tzu	2002-02-21 23:30:50.442260588 -0800
@@ -1,7 +1,6 @@
-The Way that can be told of is not the eternal Way;
-The name that can be named is not the eternal name.
 The Nameless is the origin of Heaven and Earth;
-The Named is the mother of all things.
+The named is the mother of all things.
+
 Therefore let there always be non-being,
   so we may see their subtlety,
 And let there always be being,
@@ -9,3 +8,6 @@
 The two are the same,
 But after they are produced,
   they have different names.
+They both may be called deep and profound.
+Deeper and more profound,
+The door of all subtleties!

A unified diff is a diff format, most commonly used in git diff. In a unified diff, the minus sign - corresponds to data in the from-file (old file). The plus sign + coressponds to data in the to-file (new file).

A unified diff can be applied to the from-file to get the to-file. It can also be applied in the other direction on the to-file to get the from-file.

--- from-file from-file-modification-time
+++ to-file to-file-modification-time

The first line indicates the from-file last modification time. The second line indicates the to-file last modification time. Often, there is sometimes no date and just the file names. It is also possible to create a unified diff with just the plus and minuses as the header.

Hunk header

@@ from-file-line-numbers to-file-line-numbers @@

This line is a hunk header. The hunk header indicates the region of each file that the diff recognizes as changes.

The file line numbers are usually a pair which contains the start line and line count of that file.

For example, if we have

@@ -1,7 +1,6 @@

This hunk contains lines of the from-file starting at line 1 and contains 7 lines from the from-file. This hunk also contains lines of the to-file starting at line 1 and contains 6 lines from the to-file. If the line count is 1, it can be omitted, so @@ -1 +1 @@ is shorthand for @@ -1,1 +1,1 @@.

We can verify this.

@@ -1,7 +1,6 @@
-The Way that can be told of is not the eternal Way;
-The name that can be named is not the eternal name.
 The Nameless is the origin of Heaven and Earth;
-The Named is the mother of all things.
+The named is the mother of all things.
+
 Therefore let there always be non-being,
   so we may see their subtlety,
 And let there always be being,

If a line is from the from-file, it starts with a minus. If it is from the to-file, it starts with a plus. If it does not start with anything, the line is shared between both files.

We can see that there are 3 minus lines and 4 signless lines, so there are 7 lines from the from-file.

We can also see that there are 2 plus lines and 4 signless lines, so there are 6 lines from the to-file.

Hunk

-The Way that can be told of is not the eternal Way;
-The name that can be named is not the eternal name.
 The Nameless is the origin of Heaven and Earth;
-The Named is the mother of all things.
+The named is the mother of all things.
+
 Therefore let there always be non-being,
   so we may see their subtlety,
 And let there always be being,

As mentioned in the previous section, a + is a line added to the from-file to make the to-file and a - is a line removed from the from-file to make the to-file.

A line without a sign is an unchanged line that is shared in between both files.

Unified diffs tend to combine multiple nonconsecutive but local changes together into one hunk, rather than create many small hunks.

Context

In a unified diff, you can add context, which are lines that are shared between both files that surround the changes. Context allows diffs to be more easily read by humans because we have a general idea of where that change is located in the code.

For example, given this hunk:

@@ -1,7 +1,6 @@
-The Way that can be told of is not the eternal Way;
-The name that can be named is not the eternal name.
 The Nameless is the origin of Heaven and Earth;
-The Named is the mother of all things.
+The named is the mother of all things.
+
 Therefore let there always be non-being,
   so we may see their subtlety,
 And let there always be being,

The signless lines (The Nameless is the origin of Heaven and Earth;, Therefore let there always be non-being,, etc.) are the context. They aren’t changes themselves, but they surround the changes so the reader can see where in the file the edits happened.

Applying a diff

Now that we have a general idea of the format of a diff, we can understand how to apply one onto a from-file to make a to-file. Given the from-file and a unified diff, we just need to remove the lines that start with - and add the lines that start with +.

To do so, let’s first parse the from-file into individual lines and the unified diff into individual lines. Then, we can process the from-file and unified diff lines sequentially according to the unified diff.

We can iterate through the from-file lines sequentially. There are two cases:

  1. The line we are processing in the from-file is not part of a hunk. In this case we can just copy it directly over to the output without any changes.
  2. The line we are processing in the from-file is part of a hunk in the diff. Then, we should iterate through the corresponding hunk sequentially and append the signless lines as well as the + lines. We do not need to worry about the - lines because they are removed in the to-file.

Of course, this is how a diff is applied intuitively/logically. There are many more optimized algorithms you can do to speed up diff application.

Creating a unified diff

A unified diff is actually an application of the longest common subsequence algorithm!

You can think of it as finding the longest common subsequences between two pieces of text (intersection). The remaining lines are the diffs. Relatively consecutive lines get grouped together and become a hunk.

Here is the algorithm from Python’s built-in difflib The real logic for the finding the groups of the diff is in the SequenceMatcher class in difflib. The class is quite long, so I didn’t put it in this note..

def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
                 tofiledate='', n=3, lineterm='\n'):
    r"""
    Compare two sequences of lines; generate the delta as a unified diff.
 
    Unified diffs are a compact way of showing line changes and a few
    lines of context.  The number of context lines is set by 'n' which
    defaults to three.
 
    By default, the diff control lines (those with ---, +++, or @@) are
    created with a trailing newline.  This is helpful so that inputs
    created from file.readlines() result in diffs that are suitable for
    file.writelines() since both the inputs and outputs have trailing
    newlines.
 
    For inputs that do not have trailing newlines, set the lineterm
    argument to "" so that the output will be uniformly newline free.
 
    The unidiff format normally has a header for filenames and modification
    times.  Any or all of these may be specified using strings for
    'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'.
    The modification times are normally expressed in the ISO 8601 format.
 
    Example:
 
    >>> for line in unified_diff('one two three four'.split(),
    ...             'zero one tree four'.split(), 'Original', 'Current',
    ...             '2005-01-26 23:30:50', '2010-04-02 10:20:52',
    ...             lineterm=''):
    ...     print(line)                 # doctest: +NORMALIZE_WHITESPACE
    --- Original        2005-01-26 23:30:50
    +++ Current         2010-04-02 10:20:52
    @@ -1,4 +1,4 @@
    +zero
     one
    -two
    -three
    +tree
     four
    """
 
    _check_types(a, b, fromfile, tofile, fromfiledate, tofiledate, lineterm)
    started = False
    for group in SequenceMatcher(None,a,b).get_grouped_opcodes(n):
        if not started:
            started = True
            fromdate = '\t{}'.format(fromfiledate) if fromfiledate else ''
            todate = '\t{}'.format(tofiledate) if tofiledate else ''
            yield '--- {}{}{}'.format(fromfile, fromdate, lineterm)
            yield '+++ {}{}{}'.format(tofile, todate, lineterm)
 
        first, last = group[0], group[-1]
        file1_range = _format_range_unified(first[1], last[2])
        file2_range = _format_range_unified(first[3], last[4])
        yield '@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm)
 
        for tag, i1, i2, j1, j2 in group:
            if tag == 'equal':
                for line in a[i1:i2]:
                    yield ' ' + line
                continue
            if tag in {'replace', 'delete'}:
                for line in a[i1:i2]:
                    yield '-' + line
            if tag in {'replace', 'insert'}:
                for line in b[j1:j2]:
                    yield '+' + line

Resources