Skip to main content

JSON Merge Done Right: Deep Merge, Array Strategies, and Config Layering

How to merge two or more JSON objects: deep merge vs shallow merge, why the later object wins on conflicts, array strategies, and clean config layering.

Published By Li Lei
#json #deep merge #config #developer-tools

JSON Merge Done Right: Deep Merge, Array Strategies, and Config Layering

Merging JSON looks trivial until the second object quietly eats half the first one. You paste a base config, drop an override on top, and a whole logging block disappears because the override only listed one of its keys. The fix is not a bigger spread operator. It is knowing exactly what kind of merge you are running and what your tool does with arrays.

This post walks through merging two or more JSON objects: the difference between a deep merge and a shallow one, why the later object wins on a conflict, the four sensible ways to handle arrays, and how all of that adds up to clean config layering. Examples come straight from the JSON Merge tool, which does a deep merge by default and lets you pick the array behavior.

Deep merge versus shallow merge

A shallow merge replaces a whole value when keys collide. Object.assign and the spread operator both work this way: if both sides have a key, the second side's value wins outright, even if that value is a nested object holding ten keys you wanted to keep.

{"a":{"x":1}}  shallow-merged with  {"a":{"y":2}}  =>  {"a":{"y":2}}

The x:1 is gone. The second object's a replaced the first object's a as a single unit. Nine times out of ten that is not what you meant when you set out to "combine" two configs.

A deep merge recurses. When the same key holds an object on both sides, it walks into that object and combines key by key instead of swapping the whole thing:

{"a":{"x":1}}  deep-merged with  {"a":{"y":2}}  =>  {"a":{"x":1,"y":2}}

That single rule — same-key objects recurse, everything else replaces — is the heart of a useful merge. The JSON Merge tool always does the deep version, because that is what layering partial files actually requires. If you genuinely want the later object to wipe a nested block, a deep merge will surprise you by holding onto the old keys, so it pays to know which behavior your situation needs before you trust the output.

Later object wins: the left-to-right fold

When the same key holds a scalar on both sides — a number, a string, a boolean — there is nothing to recurse into, so the merge picks one. The rule is simple: the later object wins.

{"port":8080}  merged with  {"port":9090}  =>  {"port":9090}

With three inputs the fold runs left to right: B overrides A, then C overrides B. The last value to touch a key is the one that survives. This is not an arbitrary choice. It is precisely how layered configuration is supposed to resolve: a base file first, an environment override next, and a local override last so the most specific value sticks. Order your inputs from most general to most specific and conflicts resolve themselves.

Four ways to merge arrays

Objects have one obviously correct merge. Arrays do not, because an array can mean a set, a sequence, a tuple, or a list of records, and each implies a different combine. So instead of guessing, the tool gives you four strategies:

  • Replace (default): the later array wins outright. [1,2] over [3] gives [3]. Use this when the new list is the complete, authoritative version.
  • Concat: append the later array. [1,2] then [3,4] gives [1,2,3,4]. Use this when both lists should contribute every element.
  • Dedupe: concat, then drop repeats by JSON identity. [1,2] and [2,3] give [1,2,3]. This is the one you want for allow-lists, tags, and roles.
  • By index: merge element by element and keep the longer tail. [{"x":1}] over [{"y":2}] gives [{"x":1,"y":2}]. Use this when arrays are positional records, not sets.

The most common mistake here is leaving the strategy on replace when you meant to combine. A tags or roles list quietly loses its old entries because the later array discarded the earlier one entirely. If both lists should survive, switch to concat or dedupe before you copy the result.

A worked example: base config plus production override

Here is the everyday case. You keep a base config.json with sane defaults and a tiny production.json that only lists what changes in prod.

Input A (base):

{
  "logging": { "level": "info", "format": "json" },
  "cache": { "ttl": 60 },
  "hosts": ["db-local"]
}

Input B (production override):

{
  "logging": { "level": "warn" },
  "hosts": ["db-prod"]
}

Deep merge with the array strategy on replace gives:

{
  "logging": { "level": "warn", "format": "json" },
  "cache": { "ttl": 60 },
  "hosts": ["db-prod"]
}

Read the result line by line. The logging.level flipped to warn because B is later, but logging.format survived because B never mentioned it — that is the deep merge keeping keys you did not override. The whole cache block came through untouched since B said nothing about it. And hosts became ["db-prod"] because replace took the later array whole. Had you wanted both hosts, switching to concat would have produced ["db-local","db-prod"] instead. One input, one strategy toggle, and you can see the effective config your service will actually run with.

Where I reach for this

I maintain a service whose config splits across a base file, a per-environment file, and a developer's local file. The first time a teammate's local override blanked an entire nested block, we lost an afternoon to a missing cache setting that was right there in the base file — a shallow merge had swallowed it. Now my habit is boring and reliable: paste base into A, environment into B, local into C, leave arrays on dedupe for the allow-lists, and read the merged output top to bottom before committing anything. The share link goes in the pull request so a reviewer reopens the exact same merge instead of reconstructing it in their head. It turned a recurring "why is this null in prod" hunt into a thirty-second check.

Merge versus diff, and clean inputs

A merge produces a new document; a diff only shows you what changed between two. Use a diff when you want to read the delta before a release, and a merge when you want one config out of a base plus overrides. They pair well — read the JSON Diff of two files to confirm what an override touches, then merge to produce the result. They are sibling tools for exactly this reason.

One last gotcha: the parser is strict JSON. A // comment, a trailing comma, or JSON5 syntax makes that side invalid and it drops out of the merge silently. If you are pasting hand-edited config, run it through a formatter first to get clean, strict JSON, then merge with confidence. Pick your merge type, pick your array strategy, order your inputs from general to specific, and the output stops surprising you.


Made by Toolora · Updated 2026-06-13