# Comparing two versions: unified diff and JSON paths

> How to write a unified diff from an old and a new text, and how to compare JSON by meaning, naming each changed value with a JSON Pointer path.

Published 2026-10-30 · https://aiskills402.com/blog/text-diff-and-json-diff

Two versions of the same thing can be compared in two honest ways, and the right one depends on what the thing is. A text file is a sequence of lines, so the comparison is a unified diff: a list of hunks that say which lines left and which arrived. A JSON document is a tree of values, so a text diff is the wrong tool: reordering two keys changes the text and changes nothing that a program can observe. For JSON, the useful answer is a list of paths, each naming one value that differs. This guide covers how to write both comparisons correctly, with small examples that were run on real files. How a finished diff can fail to apply is a separate subject, treated in our guide to [why a patch is rejected](/blog/unified-diff-broken-patches); here the question is how to produce a diff that is right in the first place.

## Writing a unified diff by hand

GNU diffutils documents this format in its manual. Two lines at the top name the earlier file and the later one. Then comes one block per area of difference, opened by a marker line shaped like `@@ -start,count +start,count @@` and continued by body lines that carry a space, a minus or a plus as their first character ([GNU diffutils, unified format](https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html)). A space marks a line found in both files, a minus marks one that only the earlier file has, and a plus marks one that only the later file has.

Here is a ten-line file whose fifth line changes from `e` to `E`, and the diff that git printed for it:

```diff
--- a/old.txt
+++ b/new.txt
@@ -2,7 +2,7 @@
 b
 c
 d
-e
+E
 f
 g
 h
```

In our run, git also placed the text of an earlier line after the closing pair of at-signs, as a hint about where the hunk sits. That hint is decoration, and a hand-written diff does not need it.

Read the header numbers against the body. The old range starts at line two and covers seven lines: three unchanged lines, the removed one, and three more unchanged. The new range also starts at line two and also covers seven lines, because one line was swapped for another. So the counts come from a plain sum. For the earlier file, add the space lines and the minus lines. For the later file, add the space lines and the plus lines. Write the body first and tally it afterwards, never the reverse, because a count written from memory is the single most common error in a generated diff.

Each start number is a line number in its own file, counted from the top of the hunk. The context lines belong to that span, which is why the start here is two and not five. The manual also says that a hunk of a single line shows only its start number, and that an empty hunk counts as starting at the line that follows it ([GNU diffutils, unified format](https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html)).

## Context lines and when hunks merge

The lines around a change are context, and they exist so a patch program can find the right place even if the file moved by a few lines. Git's default is three lines of context, taken from the configuration variable `diff.context` and falling back to three when that is unset, and the number can be changed with `-U<n>` ([git diff options](https://git-scm.com/docs/diff-options)). With only one line of context, the same change as above produces this:

```diff
@@ -4,3 +4,3 @@
 d
-e
+E
 f
```

Hunks are separate only while their context does not touch. We ran git on an eleven-line file with two changed lines and the default three lines of context. With six unchanged lines between the changes it wrote one hunk, because the trailing context of the first change and the leading context of the second change covered the whole gap. With seven unchanged lines between them it wrote two hunks, with a header each. Writing one yourself, you can use that as the test: if the gap is at most twice the context size, merge the two hunks into one and let the shared lines appear once. Two hunks that overlap are a bug, since a program applying them in order would see the same line twice.

One practical choice remains, which is the order inside a block of changes. When several lines are removed and several are added at the same place, all the minus lines come first and the plus lines after them. Interleaving them (a minus, a plus, a minus, a plus) describes the same final file, but it is not the shape that the tools in our runs wrote, so stay with the grouped form.

## The last line without a newline

A line that ends the file without a newline character is called an incomplete line in the GNU manual, and it never matches a full line with the same letters, unless differences in whitespace are being ignored. A diff flags it with an extra line right below, opened by a backslash ([GNU diffutils, incomplete lines](https://www.gnu.org/software/diffutils/manual/html_node/Incomplete-Lines.html)). In English that line starts with `\ No newline` and goes on to say the file stops without one; the manual warns that other locales may word it differently.

Three cases are worth knowing, and all three were produced with git on three-line files. If the old file ends without a newline and the last line changes from `c` to `C` in a file that now ends with one, the marker sits right after the minus line and nothing follows the plus line. If the only change is that the final newline was removed from an otherwise identical last line, the diff still has a minus line and a plus line with the same letters, and the marker follows the plus line, the one that now lacks the newline. And if the old file's last line gets a new line appended after it, the old `c` has to be removed and written again, because without the newline it was a different line than the `c` that now has one after it, so the diff shows that `c` as removed and added and puts the marker after the version that lacks the break.

So for the last line of each file, the question to ask is whether the file ends with a newline, and only then what the text is. The marker is not counted in either header number. It annotates the line above it. A change to the final line is therefore the one place where two files that look identical on screen produce a nonempty diff.

## Comparing JSON by meaning

JSON text can differ in ways that do not change the data. RFC 8259 defines an object as an unordered collection of name and value pairs and an array as an ordered sequence of values ([RFC 8259, section 2](https://www.rfc-editor.org/rfc/rfc8259)). Those two sentences carry most of the rules for a comparison:

- **Key order does not count.** Two objects with the same pairs in a different order are equal. A text diff lists every moved line, while a comparison by meaning lists none.
- **Array order does count.** `[1, 2]` and `[2, 1]` are different values. Inserting an item at the front of an array shifts every later position, so a comparison by index reports a change at each shifted index and one added item at the end. That can look noisy, but it is the faithful report of what an index-based consumer would see.
- **Spelling of a number does not count, its value does.** `1`, `1.0` and `1e0` are three spellings of one number once parsed. After parsing, the comparison should be between values, not strings. The RFC also warns that software may not keep more precision than a double-precision float offers, and that integers inside the range of minus 9007199254740991 to 9007199254740991 are the ones all implementations agree on ([RFC 8259, section 6](https://www.rfc-editor.org/rfc/rfc8259)). Two very long integers that differ only in their last digits can therefore compare equal in a language that parses them as doubles.
- **Types are strict.** The string `"7"` is not the number `7`, `null` is not a missing key, and `[]` is not `{}`. When the type of a value changes, that is a single change: report the old and new value in full and do not look inside either.
- **Duplicate keys cannot be compared.** The RFC says that when names inside an object are not unique, the behaviour of receiving software is unpredictable, and that many implementations keep only the last pair ([RFC 8259, section 4](https://www.rfc-editor.org/rfc/rfc8259)). In Node, parsing a text that holds the key `a` twice quietly keeps the second value. A comparison of a document that was already collapsed by the parser cannot see what the text said, so a careful tool refuses such input.

## Naming a changed value with a JSON Pointer

A path to a value needs a notation, and RFC 6901 defines one. A JSON Pointer is a string of reference tokens, each preceded by a slash, and the empty string points at the whole document ([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)). For an object the token is the member name; for an array it is the zero-based index written in decimal with no leading zeros.

Two characters need escaping inside a token, because the slash separates tokens and the tilde is the escape character. The RFC says a tilde is written `~0` and a slash is written `~1`, and it fixes the order for reading them back: first replace `~1` with a slash, then replace `~0` with a tilde. The RFC explains why: reading them in the opposite order would turn the text `~01` into `~1` and then into a slash, while the correct result is the two characters `~1` ([RFC 6901, section 4](https://www.rfc-editor.org/rfc/rfc6901)). When writing a pointer, you apply the mirror image: escape the tilde first, then the slash. A key called `a/b` becomes the token `a~1b`, and a key called `m~n` becomes `m~0n`.

A pointer that ends in the single character `-` refers to the element after the last one in an array, which does not exist, so the RFC says it always raises an error when evaluated. A change report should not produce it: an added array item is named by its real index.

## A small comparison that you can run

The rules above fit in a short function. It reports one entry per path with the operation `added`, `removed` or `changed`, compares arrays by position, and escapes keys as described. The numbers are compared after `JSON.parse`, so `1.0` and `1` are the same.

```js
const esc = (k) => k.replaceAll('~', '~0').replaceAll('/', '~1');
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);

function diff(a, b, path = '', out = []) {
  if (kind(a) !== kind(b)) out.push({ path, op: 'changed', old: a, new: b });
  else if (kind(a) === 'object') {
    for (const k of Object.keys(a))
      if (!(k in b)) out.push({ path: path + '/' + esc(k), op: 'removed', old: a[k] });
      else diff(a[k], b[k], path + '/' + esc(k), out);
    for (const k of Object.keys(b))
      if (!(k in a)) out.push({ path: path + '/' + esc(k), op: 'added', new: b[k] });
  } else if (kind(a) === 'array') {
    for (let i = 0; i < Math.max(a.length, b.length); i++) {
      if (i >= b.length) out.push({ path: `${path}/${i}`, op: 'removed', old: a[i] });
      else if (i >= a.length) out.push({ path: `${path}/${i}`, op: 'added', new: b[i] });
      else diff(a[i], b[i], `${path}/${i}`, out);
    }
  } else if (a !== b) out.push({ path, op: 'changed', old: a, new: b });
  return out;
}
```

Given the old text `{"code":"5","items":["x","y"],"ratio":2.0}` and the new text `{"ratio":2,"items":["z","x","y"],"code":5}`, it reports four entries: `/code` changed from the string to the number, `/items/0` changed from `x` to `z`, `/items/1` changed from `y` to `x`, and `/items/2` added with the value `y`. There is nothing for `ratio`, since the value did not change, and nothing for the reordered keys. Given an old object with the keys `a/b` and `m~n` and a new object where `m~n` has a different value and `a/b` is gone, it prints the paths `/a~1b` and `/m~0n`.

The output is a report, not a recipe. RFC 6902 defines JSON Patch, a list of operations such as `add`, `remove` and `replace`, each with a path ([RFC 6902](https://www.rfc-editor.org/rfc/rfc6902)), and the report above maps to it directly. A patch that is meant to be applied also needs rules for moves and for array positions, and a report of differences does not decide those.

## Checks that can fail

A comparison is only trustworthy if you have seen it go red. For the text diff, run your own output through `git apply` on the old file, then check that the outcome equals the new file exactly. Then make the test fail on purpose: change one header count, delete the backslash marker from a case where it belongs, and confirm that the application or the final comparison breaks each time. A test that stays green after those edits is checking something other than the diff.

For the JSON comparison, keep a small table of pairs with a known answer, and include the hostile ones: the same data with keys shuffled (zero entries expected), `1.0` against `1` (zero), `"1"` against `1` (one entry), a missing key against `null` (one entry), an empty array against an empty object (one entry), keys with a slash and a tilde in the name, an array that gained an item at the front, and a top-level type change (one entry with the empty path). Then break the function on purpose by removing the escaping or by comparing numbers as text, and confirm that at least one row turns red. If none does, the table lacks a case.

## When this does not apply

The diff part covers plain-text files in unified format. Binary files, renames, mode changes and the context and ed-script formats are not covered, and neither is a smarter alignment of moved blocks, since a line-by-line comparison can produce a legal but longer diff than a person would write. The merge rule and the marker cases come from small runs of one git version on tiny files, so a different version or a different set of options can print differently. The JSON part assumes valid JSON with unique keys, compares arrays by position without detecting moves, and does not handle numbers beyond double precision, where the parsed value is already approximate. A report of paths says where two documents differ, not whether the difference matters, and cannot stand in for validation against a schema. Judging which differences in an API description would break a client is a further step, and we discuss that question for API descriptions in a separate post about [what breaks a client](/blog/openapi-breaking-changes). No timing or large-file behaviour was measured.

## Two skills for these jobs

Our [diff-from-before-after](https://aiskills402.com/skills/diff-from-before-after) skill receives the earlier and later text of one file and replies with nothing except the unified diff: it derives the numbers of every hunk header from the lines beneath it, copies context lines character for character from the file, joins hunks whose context meets, adds the backslash line when the closing newline is absent, and copes with new files, emptied files and repeated lines. When the two inputs are the same, it answers with a short fixed phrase rather than an empty patch.

Our [json-diff-paths](https://aiskills402.com/skills/json-diff-paths) skill takes two JSON documents and returns one entry per differing value as JSON, with the pointer path, the operation and the old and new values. Key order and number spelling are left out of the comparison, types are kept apart, arrays are matched by index, and turns down any input that fails to parse rather than mending it.
