Practical infra coding

Diff two JSON configs by key path

mediumCSV, JSON and YAML

Problem statement

Staging works and production does not. Compare the two configs and report every difference by key path, sorted by path:

  • + path: value exists only in the second file.
  • - path: value exists only in the first file.
  • ~ path: old -> new exists in both with a different value.

Values print as JSON, so a string "5432" is visibly different from the number 5432. That exact type change is one of the differences here, and a text diff of the two files would not make it obvious. Like diff, exit with 1 when the files differ and 0 when they match.

staging.json

JSON
{
"replicas": 2,
"image": "shop/api:1.4.0",
"database": {"host": "db.staging", "port": 5432, "pool": 10},
"features": {"new_ui": true},
"debug": true
}

prod.json

JSON
{
"replicas": 6,
"image": "shop/api:1.4.0",
"database": {"host": "db.prod", "port": "5432", "pool": 10, "replica_host": "db-ro.prod"},
"features": {"new_ui": false}
}

Examples

Example 1

Input: python solution.py staging.json prod.json

Output: ~ database.host: "db.staging" -> "db.prod" ~ database.port: 5432 -> "5432" + database.replica_host: "db-ro.prod" - debug: true ~ features.new_ui: true -> false ~ replicas: 2 -> 6

Explanation: image and database.pool are equal and are not printed. The port changed type from number to string, which is often the real cause of a production failure.

Hints

Approach

Optimal

Reduce the tree comparison to a dict comparison.

  1. flatten turns each document into (path, leaf) pairs, the same function as in the flattening problem.
  2. Build two dicts, take the union of their keys, and sort it for stable output.
  3. A key only in a is removed, only in b is added, and in both with not same(...) is changed.
  4. same requires type(a) is type(b). Without it, 1 -> true or 8080 -> 8080.0 would be reported as unchanged, even though a strict consumer treats them differently.

Sorting the paths is O(n log n). Because leaves are compared, a subtree that is replaced shows as individual removed and added leaves, which makes it obvious what moved.

ComplexityTime O(n log n)Space O(n)
Python
import json
import sys
def flatten(value, prefix=""):
if isinstance(value, dict) and value:
for k, v in value.items():
yield from flatten(v, f"{prefix}.{k}" if prefix else str(k))
elif isinstance(value, list) and value:
for i, v in enumerate(value):
yield from flatten(v, f"{prefix}[{i}]")
else:
yield prefix, value
def same(a, b):
# In Python True == 1 and 1 == 1.0, but a config that flips 1 to true has changed.
return type(a) is type(b) and a == b
def diff(old, new):
a, b = dict(flatten(old)), dict(flatten(new))
for key in sorted(a.keys() | b.keys()):
if key not in b:
yield f"- {key}: {json.dumps(a[key])}"
elif key not in a:
yield f"+ {key}: {json.dumps(b[key])}"
elif not same(a[key], b[key]):
yield f"~ {key}: {json.dumps(a[key])} -> {json.dumps(b[key])}"
def load(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
sys.exit(f"{path}:{e.lineno}:{e.colno}: invalid JSON: {e.msg}")
def main(old_path, new_path):
changes = list(diff(load(old_path), load(new_path)))
for line in changes:
print(line)
return 1 if changes else 0 # like diff(1): exit 1 when the files differ
if __name__ == "__main__":
args = sys.argv[1:] or ["staging.json", "prod.json"]
sys.exit(main(args[0], args[1]))

Follow-up questions

  • Compare lists of objects by a key field (name) instead of by index.
  • Ignore paths listed in an allow-list, such as replicas, which is expected to differ.
  • Colour the output when stdout is a terminal and keep it plain when piped.

Frequently asked questions

A line diff depends on formatting and key order: reorder two keys and every line changes. It also reports lines, not paths, so "port": 5432, next to "port": "5432", is easy to miss. A structural diff ignores formatting and names exactly which setting changed.

"It works in staging but not in prod" is one of the most common tickets, and config drift is a frequent cause. A structural diff is what terraform plan, kubectl diff and Argo CD show you. Writing a small one checks recursion, set operations and attention to types.

By index, because flatten gives list items paths like ports[0]. Inserting one item at the front makes every later index look changed. For lists of named objects, such as containers, match items by their name field first.