Git merge conflicts in JSON files: why merge=union writes broken JSON
Bottom line: merge=union lets broken JSON through and still reports success. This walks through a key-aware three-way merge driver for JSON, plus three habits that cut conflicts.

Bottom line: standardize serialization and key order before discussing merge strategy; every conflict merge=union saves comes back later as a missing comma, a duplicate key, or two stacked documents, so JSON conflicts belong to a key-aware three-way merge driver.
Two people add two translation keys in the same week: one adds tools.diff, the other adds tools.schema. The changes are unrelated and semantically compatible. Git still reports a conflict, and the conflict block holds both lines:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
The reason is simple. Both branches inserted a new line just before the closing brace, and Git's line-based three-way merge only sees position, not meaning. It has no concept of "these are two different keys". So someone reads the .gitattributes documentation and copies one line:
i18n.json merge=union
The conflict disappears. What it costs is the subject of this article.
1. Union does not merge, it keeps both sides' lines
merge=union is documented plainly: run a three-way file-level merge for text files, but take lines from both versions instead of leaving conflict markers. The documentation follows with a warning — do not use this if you do not understand the implications. That warning is not boilerplate.
Union does not understand objects, keys, or arrays. It understands lines. Every result below was reproduced in a local repository using exactly the i18n.json above.
| Scenario | Default driver | merge=union |
Key-aware driver |
|---|---|---|---|
| Each side adds a different key | Conflict, manual | "Merged", missing comma, invalid JSON | Merged, both keys kept |
| Both sides change the same key | Conflict, manual | "Merged", duplicate key, last one wins | Exit code 1, conflicting paths reported |
| Single-line JSON, one change each side | Conflict, pick a whole file | "Merged", two documents stacked | Merged, but reformatted |
One side deletes minify, other edits parse |
Conflict, manual | "Merged", deleted key comes back, comma missing | Merged, deletion kept, edit applied |
Only the middle column makes git merge return success, and none of its output parses. That is the counterintuitive part of union on JSON: the quieter it is, the more dangerous it is.
2. Result one: a missing comma, reported as a clean merge
Same two branches, now with merge=union in place:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
No conflict, merge commit created. The file looks like this:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比"
"schema": "Schema 生成"
}
}
There is no comma after "diff": "对比". Union kept the line from our side and the line from their side, each carrying its own trailing comma, and the seam lost one separator.
Parsing it:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
This is the worst part: Git considers the merge successful, the CI merge step passes, and code review sees a green diff of "1 insertion". The failure waits until the first JSON.parse at runtime, by which point the merge is far behind you.
3. Result two: a duplicate key that silently drops data
The second experiment uses a different shape: both sides modify the same key, one to "解析 / 格式化", the other to "解析器". After union:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
This time the JSON is valid and JSON.parse raises nothing. But parse appears twice. Per object literal semantics the later entry wins, so the final value is "解析器" — the other edit is discarded with no log line and no error.
Only a duplicate-key check surfaces it:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
This is harder to find than invalid JSON: the file loads, the page renders, and one string quietly reverted. Weeks later someone asks why a translation never shipped.
4. Result three: single-line JSON becomes two documents
Some config files are minified to a single line, for example build artifacts. There are no lines for union to merge, so both sides are whole lines and the result stacks them:
{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}
JSON.parse reports Unexpected non-whitespace character after JSON at position 63 — it finished the first document and found a second. Git again says "Merge made".
Minified JSON should not be in version control at all, but once it is, union turns it into two unrelated documents.
5. Result four: the deleted key comes back
The last shape is the most deceptive. One side deletes minify; the other edits the adjacent parse. The default driver reports a conflict; union produces:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
Three defects at once: minify is resurrected, parse appears twice, and the first line lost its comma. Git still reports "2 insertions" and a successful merge.
The general lesson: in a deletion, "keep lines from both sides" is equivalent to undoing the deletion. Any team that has used union on JSON for a while has a set of "we deleted that" settings quietly living on in history.
6. The correct approach: three-way merge by key
Git lets you assign a custom merge driver to specific paths. The driver receives three file paths: %O is the common ancestor, %A is ours (the result is written back here), %B is theirs. A few dozen lines of Node are enough:
import { readFileSync, writeFileSync } from 'node:fs';
const [basePath, oursPath, theirsPath] = process.argv.slice(2);
const read = (filePath) => JSON.parse(readFileSync(filePath, 'utf8'));
const [base, ours, theirs] = [read(basePath), read(oursPath), read(theirsPath)];
const sortKeys = (value) => Array.isArray(value)
? value.map(sortKeys)
: value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]))
: value;
const same = (left, right) => JSON.stringify(sortKeys(left)) === JSON.stringify(sortKeys(right));
const conflicts = [];
function merge3(baseValue, oursValue, theirsValue, path) {
if (same(oursValue, theirsValue)) return oursValue;
if (same(oursValue, baseValue)) return theirsValue;
if (same(theirsValue, baseValue)) return oursValue;
const allObjects = [oursValue, theirsValue, baseValue].every(
(value) => value && typeof value === 'object' && !Array.isArray(value),
);
if (allObjects) {
const keys = new Set([...Object.keys(baseValue ?? {}), ...Object.keys(oursValue), ...Object.keys(theirsValue)]);
const merged = {};
const has = (source, key) => Boolean(source) && Object.hasOwn(source, key);
for (const key of keys) {
const child = merge3(
has(baseValue, key) ? baseValue[key] : undefined,
has(oursValue, key) ? oursValue[key] : undefined,
has(theirsValue, key) ? theirsValue[key] : undefined,
path ? `${path}.${key}` : key,
);
if (child !== undefined) merged[key] = child;
}
return merged;
}
conflicts.push({ path: path || '(root)', ours: oursValue, theirs: theirsValue });
return oursValue;
}
const merged = merge3(base, ours, theirs);
if (conflicts.length > 0) {
writeFileSync('merge-conflicts.json', `${JSON.stringify({ file: oursPath, conflicts }, null, 2)}\n`, 'utf8');
console.error(`${conflicts.length} real conflict(s), see merge-conflicts.json`);
process.exit(1);
}
writeFileSync(oursPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
process.exit(0);
Three rules carry it: identical on both sides, take it; one side equals the ancestor, take the other; differing on both sides is a real conflict. Everything else is recursion into plain objects.
Registration takes two steps — .gitattributes picks the driver per path, git config defines the driver:
git config merge.json-merge.name "JSON key merge"
git config merge.json-merge.driver "node merge-json.mjs %O %A %B"
echo "i18n.json merge=json-merge" >> .gitattributes
The first can be committed (it travels with the code and applies in CI). The second is local configuration and needs to be in the README — this is where custom drivers usually fall apart.
For the "each side adds a different key" case, the driver produces:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
Exit code 0, valid JSON, both keys present.
One side effect to know about: the driver writes JSON.stringify(merged, null, 2), so it rewrites the whole file's formatting. A minified 63-byte config came back as 7 lines and 105 bytes — same content, different shape. That makes the driver a fit for hand-maintained source files only. Minified or generated JSON should not be in the repository, and should not use this driver, or every merge will drag along formatting noise.
7. Do not write back on conflict — this trap is bigger than it looks
When both sides change the same key, what should the driver do? The intuitive move is "write ours back, exit 1, let Git report the conflict". Tested, that is dangerous:
$ git merge conf-b
CONFLICT (content): Merge conflict in i18n.json
Automatic merge failed; fix conflicts and then commit the result.
$ grep -c '^<<<<<<<' i18n.json
0
Git reports a conflict, but there is not a single conflict marker in the file — the driver already wrote its result. The content looks completely normal, holding our side's value. One hasty git add i18n.json && git commit and their change is gone, with nothing but "Merge conflict resolved" in the history.
So on a real conflict the driver should not write %A. Record the undecided entries in a report file and exit 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
The value is that git status stops at UU, so a human must act — and they do not have to hunt for <<<<<<< inside dozens of JSON lines. The report says exactly which paths collided.
One more tested finding: git checkout --conflict=merge -- i18n.json cannot restore conflict markers once a custom driver is registered, because it invokes the driver again and still yields zero markers. To get markers back you must temporarily git config --unset merge.json-merge.driver, or pull the three stages with git show :1:file, :2:, :3:. Anyone counting on "I can always regenerate the markers" finds that road closed exactly when they need it.
8. Three habits that reduce conflicts up front
The driver is a backstop, not the main tool. The number of conflicts is itself reducible.
Standardize serialization. One person writes indent=2, another indent=4, a third omits the trailing newline. Every save rewrites the whole file and merge has nothing to work with. Pin the formatter config, or run it in a pre-commit hook.
Keep key order stable. Key order is meaningless to JSON but very real to Git. Sorting keys before writing (sort before JSON.stringify, or pipe through jq -S) keeps one edit to one hunk. This is the same idea as normalizing before comparing in how two JSON documents differ — there for diffing, here for committing.
Keep generated files out of the repository. Minified JSON, build manifests with timestamps, lockfiles — they change on every run. Version the source, generate the artifact.
9. Checklist
- If
.gitattributesmaps JSON tomerge=union, sample the history withgit log -pfor duplicate keys or syntax errors, then replace it; - After registering a custom driver, have everyone run the
git configstep, or put it in amake setup; - The driver's conflict branch must not write
%A; emit a report instead; - Add a
node -e "JSON.parse(...)"orjq emptygate in CI, so "valid but wrong" JSON is caught after merge and before deploy; - Add minified JSON and generated files to
.gitignoreto remove that class of conflict entirely.
10. FAQ
When is merge=union safe?
Only for append-only files where each line is a complete record and order carries no meaning, such as JSON Lines logs. As soon as a line can be edited in place — adding a field to an existing record — union produces two contradictory records. That is a design boundary, not a matter of using it carefully.
Can a custom driver handle arrays?
The version above treats arrays as atomic: if both sides change an array, it reports a conflict. Index alignment reproduces the old text-diff problem (insert one item and everything shifts), while id alignment requires an agreed primary key. Array merges are better left to a human, or prevented by a "arrays are append-only" convention.
What if the driver has a bug?
It corrupts files quietly. Test the first version in a scratch repository against three shapes (each side adds a key, both change the same key, one deletes while the other edits) before rolling it out. A broken driver rewrites the file silently, which is much harder to notice than a conflict marker.
Why not just merge with one line of jq?
jq -s '.[0] * .[1]' is a shallow merge, its key order and nested behavior need checking case by case, and it has no ancestor — without base you cannot distinguish "deleted" from "unchanged". A three-way merge needs three inputs.
Should the conflict report be committed?
No. Add it to .gitignore; it is a one-off artifact that expires when the merge ends.