Diff two JSON configs by key path
Problem statement
Staging works and production does not. Compare the two configs and report every difference by key path, sorted by path:
+ path: valueexists only in the second file.- path: valueexists only in the first file.~ path: old -> newexists 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
{ "replicas": 2, "image": "shop/api:1.4.0", "database": {"host": "db.staging", "port": 5432, "pool": 10}, "features": {"new_ui": true}, "debug": true}prod.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.
flattenturns each document into(path, leaf)pairs, the same function as in the flattening problem.- Build two dicts, take the union of their keys, and sort it for stable output.
- A key only in
ais removed, only inbis added, and in both withnot same(...)is changed. samerequirestype(a) is type(b). Without it,1 -> trueor8080 -> 8080.0would 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.
O(n log n)Space O(n)import jsonimport 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.