Loading two 200MB JSON export files into memory simultaneously in Java can exhaust the default heap — the JVM's default maximum heap is a quarter of physical RAM, so whether it fits is a property of the machine, not of your code. A naive recursive diff pulls both documents fully into memory before the algorithm even starts, and the trees are several times larger than the files. Large JSON comparison is a problem that requires a different approach from small JSON comparison — not just more memory, but a fundamentally different algorithm and tooling strategy. This guide covers the techniques that actually work.
Why naive JSON comparison breaks at scale
Most JSON libraries load the entire document into a tree structure in memory before comparing. The tree is always bigger than the file, but there is no universal multiplier — it is a property of the document's shape. A flat array of records expands modestly; a deeply nested document of the same byte size expands several times more, because every level of nesting adds another container object with its own map. Measure your own worst-case document rather than budgeting from a rule of thumb, because both files have to be resident before the diff algorithm even starts.
// Memory footprint of JSON parsing in Java
// Jackson ObjectMapper (tree model):
//
// Object headers are not where the memory goes. A HotSpot object header is
// 12-16 bytes, not 40 - JEP 450: "object headers occupy between 96 bits
// (12 bytes) and 128 bits (16 bytes)". So 1,000,000 nodes cost roughly 16MB
// in node objects: real, but small beside what actually dominates - the map
// entries holding each object's fields, and the key and value Strings.
//
// A 50MB JSON file -> ~200MB in memory as a Jackson tree (flat record data)
// Two of those is enough to make a 512MB heap uncomfortable. It may still
// complete; you are just budgeting on luck.
//
// Streaming approach uses ~1-5MB regardless of file size (buffer only)Strategy 1 — Hash-based comparison (fastest for "are these equal?")
If your question is simply "are these two files identical?" rather than "what changed?", a hash comparison is orders of magnitude faster than diffing. Compute a canonical hash of each file and compare the hashes. The trick is canonical JSON: you must normalise key ordering and whitespace before hashing, otherwise the same data with different formatting produces different hashes.
// Java - canonical hash comparison
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.OutputStream;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
public class JsonHashComparator {
// Sort keys canonically before hashing.
// USE_BIG_DECIMAL_FOR_FLOATS matters more than it looks: without it every
// float is coerced to a double, so 0.1234567890123456789 and
// 0.12345678901234568 produce the same hash and the comparator calls two
// different documents identical. On money data that is not acceptable.
private static final ObjectMapper CANONICAL = new ObjectMapper()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true);
public static String canonicalHash(Path jsonFile) throws Exception {
// Parse once, re-serialize with sorted keys (normalises whitespace too)
Object tree = CANONICAL.readValue(jsonFile.toFile(), Object.class);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
// Digest the canonical form as it is produced. writeValueAsBytes()
// would buffer the entire serialisation in a byte[] on top of the
// tree, roughly doubling peak memory.
try (DigestOutputStream out =
new DigestOutputStream(OutputStream.nullOutputStream(), sha256)) {
CANONICAL.writeValue(out, tree);
}
return HexFormat.of().formatHex(sha256.digest());
}
public static boolean areEqual(Path file1, Path file2) throws Exception {
return canonicalHash(file1).equals(canonicalHash(file2));
}
}
// Usage
boolean same = JsonHashComparator.areEqual(
Path.of("response_before.json"),
Path.of("response_after.json")
);
System.out.println(same ? "Files are semantically identical" : "Files differ");Limitation: this still loads the full file into memory for normalisation, so peak usage is the parsed tree for one file at a time. If you use the more common writeValueAsBytes() form instead of the DigestOutputStreamabove, peak is the tree plus the entire canonical serialisation held as abyte[] — roughly double, and enough to turn a file that would have hashed fine into an OutOfMemoryError. For files large enough that one tree does not fit comfortably, use the streaming approach below.
Strategy 2 — Streaming comparison with Jackson (memory scales with nesting depth)
Jackson's streaming API (JsonParser) reads tokens one at a time without building a tree. You can compare two JSON files simultaneously by reading them in lockstep and comparing token by token. The only state you need to carry is one frame per open container, so memory scales with nesting depth rather than with file size. The trap here is carrying a frame per field instead: push a field name and forget to pop it once its value is consumed, and the stack grows for the life of the file, which is exactly the unbounded growth streaming was supposed to avoid.
// Java - streaming JSON comparison (state = one frame per open container)
import com.fasterxml.jackson.core.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
public class StreamingJsonComparator {
// One frame per open container; depth = nesting depth, not field count.
private static final class Frame {
final boolean isObject;
String field;
int index = -1;
Frame(boolean isObject) { this.isObject = isObject; }
String segment() { return isObject ? "." + field : "[" + index + "]"; }
}
public static List<String> diff(Path left, Path right) throws IOException {
var factory = new JsonFactory();
var differences = new ArrayList<String>();
try (JsonParser p1 = factory.createParser(left.toFile());
JsonParser p2 = factory.createParser(right.toFile())) {
Deque<Frame> frames = new ArrayDeque<>();
while (true) {
JsonToken t1 = p1.nextToken();
JsonToken t2 = p2.nextToken();
if (t1 == null && t2 == null) break;
if (t1 == null || t2 == null) {
differences.add("Structural: one file ended before the other at " + currentPath(frames));
break;
}
if (t1 != t2) {
differences.add("Type mismatch at " + currentPath(frames) + ": " + t1 + " vs " + t2);
break;
}
if (t1 == JsonToken.FIELD_NAME) {
String f1 = p1.currentName();
String f2 = p2.currentName();
if (!f1.equals(f2)) {
differences.add("Structure diverges inside " + objectPath(frames)
+ ": \"" + f1 + "\" vs \"" + f2 + "\" - different key order, or a key"
+ " was added/removed. A lockstep walk cannot resynchronise; stopping.");
break;
}
if (!frames.isEmpty()) frames.peek().field = f1;
} else if (t1 == JsonToken.START_OBJECT || t1 == JsonToken.START_ARRAY) {
beforeValue(frames);
frames.push(new Frame(t1 == JsonToken.START_OBJECT));
} else if (t1 == JsonToken.END_OBJECT || t1 == JsonToken.END_ARRAY) {
frames.pop();
} else if (t1.isScalarValue()) {
beforeValue(frames);
String v1 = p1.getText();
String v2 = p2.getText();
if (!v1.equals(v2)) {
differences.add("Value differs at " + currentPath(frames) + ": " + v1 + " -> " + v2);
}
}
if (differences.size() > 1000) {
differences.add("... truncated (too many differences)");
break;
}
}
}
return differences;
}
private static void beforeValue(Deque<Frame> frames) {
Frame f = frames.peek();
if (f != null && !f.isObject) f.index++;
}
// Path of the object we are currently inside (ignores the pending field name).
private static String objectPath(Deque<Frame> frames) {
StringBuilder sb = new StringBuilder("$");
Iterator<Frame> it = frames.descendingIterator();
for (int i = 0, n = frames.size() - 1; i < n; i++) sb.append(it.next().segment());
return sb.toString();
}
private static String currentPath(Deque<Frame> frames) {
StringBuilder sb = new StringBuilder("$");
for (Iterator<Frame> it = frames.descendingIterator(); it.hasNext(); ) sb.append(it.next().segment());
return sb.toString();
}
}Note the hard limitation, which is bigger than key ordering: a lockstep walk cannot report added or removed keys at all. The moment one file has a field the other does not, the two token streams are permanently out of step, and every subsequent comparison is between unrelated tokens — which is why the version above stops and says so rather than emitting a long list of fictitious differences. Use this strategy when you expect two structurally identical documents and want to find changed values cheaply. For added and removed records, use the key-indexed approach in Strategy 4.
Strategy 3 — jq on the command line (best for ad-hoc investigation)
jq is a lightweight command-line JSON processor. Its real advantage over a browser-based diff is not streaming — it is that it is not a browser tab, and it composes with diff, sort and the rest of the Unix toolkit. By default jq parses the entire input value into memory, and a deeply nested document costs it far more than a flat one. For inputs genuinely too large for that, streaming is opt-in via --stream, which emits [path, value] pairs instead of building the document. None of the convenience snippets below use it, so size your input accordingly.
# Install jq
# Ubuntu/Debian: sudo apt install jq
# macOS: brew install jq
# Windows: choco install jq
# The diff <(...) form below is bash/zsh process substitution. On Windows,
# run these under WSL or Git Bash, or write each side to a temp file first.
# Sort keys canonically then diff
diff <(jq --sort-keys . file1.json) <(jq --sort-keys . file2.json)
# Extract and compare specific fields only.
# Note this does NOT reduce memory: jq still parses the whole document before
# it can project anything. It only reduces what you have to read.
diff <(jq '.orders[].id' file1.json) <(jq '.orders[].id' file2.json)
# Same projection, but genuinely streaming - use this when the file is too
# large to parse whole. --stream emits [path, value] pairs as it reads.
diff \
<(jq -n --stream 'inputs | select(length==2) | select(.[0][-1]=="id") | .[1]' file1.json) \
<(jq -n --stream 'inputs | select(length==2) | select(.[0][-1]=="id") | .[1]' file2.json)
# Compare two large arrays of objects, sorted by a key field
diff <(jq '[.[] | {id, status, total}] | sort_by(.id)' orders_before.json) <(jq '[.[] | {id, status, total}] | sort_by(.id)' orders_after.json)
# Find objects present in file1 but not file2 (by ID field)
jq --slurpfile f2 file2.json '
. as $f1 |
$f2[0] | map(.id) as $f2ids |
$f1 | map(select(.id | IN($f2ids[]) | not))
' file1.json
# Count differences without printing all of them
diff <(jq --sort-keys . f1.json) <(jq --sort-keys . f2.json) | grep "^[<>]" | wc -lStrategy 4 — Key-indexed comparison for large arrays
When your large JSON is primarily a huge array (e.g., a data export of 500,000 records) and each element has a stable identifier, index both sides by that identifier and compare by key. This is the only strategy here that reports added and removed records correctly. Be clear about the cost: it holds both indexes in memory, so it is O(n) in the number of records and both files must fit in the heap. That is unavoidable for this problem — it is not a low-memory technique, and it does not become one by processing the array in chunks.
// Java - key-indexed array comparison
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
public class KeyIndexedArrayComparator {
private final ObjectMapper mapper = new ObjectMapper();
public record Index(Map<String, JsonNode> byKey, int unkeyed, int duplicates) {}
public record Result(List<JsonNode> added, List<JsonNode> removed, List<JsonNode> changed,
int leftUnkeyed, int leftDuplicates,
int rightUnkeyed, int rightDuplicates) {}
public Result compareArrays(Path left, Path right, String keyField) throws IOException {
Index l = indexByKey(left, keyField);
Index r = indexByKey(right, keyField);
List<JsonNode> added = new ArrayList<>();
List<JsonNode> removed = new ArrayList<>();
List<JsonNode> changed = new ArrayList<>();
r.byKey().forEach((key, rightNode) -> {
JsonNode leftNode = l.byKey().get(key);
if (leftNode == null) added.add(rightNode);
else if (!leftNode.equals(rightNode)) changed.add(rightNode);
});
l.byKey().forEach((key, leftNode) -> {
if (!r.byKey().containsKey(key)) removed.add(leftNode);
});
return new Result(added, removed, changed,
l.unkeyed(), l.duplicates(), r.unkeyed(), r.duplicates());
}
private Index indexByKey(Path file, String keyField) throws IOException {
Map<String, JsonNode> index = new LinkedHashMap<>();
int unkeyed = 0, duplicates = 0;
try (JsonParser parser = mapper.getFactory().createParser(file.toFile())) {
if (parser.nextToken() != JsonToken.START_ARRAY)
throw new IllegalArgumentException("Expected JSON array at root");
while (parser.nextToken() != JsonToken.END_ARRAY) {
JsonNode node = mapper.readTree(parser);
JsonNode keyNode = node.path(keyField);
if (keyNode.isMissingNode() || keyNode.isNull() || keyNode.asText().isEmpty()) {
unkeyed++;
continue;
}
if (index.put(keyNode.asText(), node) != null) duplicates++;
}
}
return new Index(index, unkeyed, duplicates);
}
}
// Usage
var comparator = new KeyIndexedArrayComparator();
var result = comparator.compareArrays(
Path.of("export_yesterday.json"),
Path.of("export_today.json"),
"order_id" // key field to identify unique records
);
System.out.println("Added: " + result.added().size());
System.out.println("Removed: " + result.removed().size());
System.out.println("Changed: " + result.changed().size());
// Always print these. An element with no key, or a second element sharing a
// key, is an element the comparison never looked at - silently dropping it and
// then reporting "no differences" is the worst failure mode a diff tool has.
System.out.println("Not compared - left: " + result.leftUnkeyed() + " unkeyed, "
+ result.leftDuplicates() + " duplicate-keyed (overwritten)");
System.out.println("Not compared - right: " + result.rightUnkeyed() + " unkeyed, "
+ result.rightDuplicates() + " duplicate-keyed (overwritten)");Strategy 5 — Database-assisted comparison for very large datasets
For JSON exports over 1GB, the fastest comparison is often to load both into a database and use SQL. PostgreSQL's JSONB operators handle this efficiently:
-- Load both JSON exports into temp tables
CREATE TEMP TABLE export_before (data JSONB);
CREATE TEMP TABLE export_after (data JSONB);
-- Import. Convert your export to JSONL (one object per line) first - a plain
-- JSON array does not import: pretty-printed, \copy fails outright with
-- "invalid input syntax for type json"; on a single line it loads the entire
-- array into ONE row as a jsonb array, so every query below silently returns
-- nothing.
-- \copy export_before (data) FROM PROGRAM 'cat export_yesterday.jsonl'
-- \copy export_after (data) FROM PROGRAM 'cat export_today.jsonl'
-- Backslash hazard: \copy defaults to FORMAT text, which interprets
-- backslash escapes, so a literal \t inside a JSON string arrives as a real
-- tab. Import as CSV with delimiters that cannot occur in JSON to keep the
-- bytes intact:
-- (psql meta-commands must be on one line - no backslash continuation)
-- \copy export_before (data) FROM PROGRAM 'cat export_yesterday.jsonl' (FORMAT csv, QUOTE E'\x01', DELIMITER E'\x02')
-- Find records changed between exports (using order_id as key)
SELECT
b.data->>'order_id' AS order_id,
b.data AS before,
a.data AS after
FROM export_before b
JOIN export_after a ON b.data->>'order_id' = a.data->>'order_id'
WHERE b.data != a.data -- JSONB != operator does semantic comparison
-- Records in before but not after (deleted)
SELECT data->>'order_id' FROM export_before
EXCEPT
SELECT data->>'order_id' FROM export_after;
-- Records in after but not before (new)
SELECT data->>'order_id' FROM export_after
EXCEPT
SELECT data->>'order_id' FROM export_before;Performance comparison — which strategy for which file size
┌───────────────────────┬──────────┬──────────┬──────────────┬─────────────────┐
│ Strategy │ < 1 MB │ 1-50 MB │ 50-500MB │ > 500 MB │
├───────────────────────┼──────────┼──────────┼──────────────┼─────────────────┤
│ Online tool / paste │ Fast │ Slow │ No │ No │
│ Hash comparison │ Fast │ Fast │ Heap-bound │ Heap-bound │
│ Jackson streaming │ Fast │ Fast │ Fast │ Fast │
│ jq + diff (default) │ Fast │ Fast │ Heap-bound │ Needs --stream │
│ jq --stream │ Fast │ Fast │ Fast │ Fast │
│ Key-indexed (Java) │ Fast │ Fast │ O(n) heap │ O(n) heap │
│ Database (Postgres) │ Overkill │ Fast │ Fast │ OK, via JSONL │
└───────────────────────┴──────────┴──────────┴──────────────┴─────────────────┘
"Heap-bound" means it keeps working until the document no longer fits in the
heap you gave the JVM (or, for jq, in RAM). Where that point falls depends on the
machine and on how deeply the document nests, not on file size alone - so measure
it rather than reading a threshold off a table like this one.
Key-indexed comparison holds both indexes at once: O(n) in records, both files
resident. That is unavoidable if you want added and removed records reported,
but it is not a low-memory technique and should not be sold as one.Reducing JSON file size before comparing
Sometimes the best strategy is making the file smaller before comparing. Remove fields you don't care about, or compare only the fields that matter:
# jq: strip timestamps and metadata before comparing
# (avoid false positives from fields that always change)
diff <(jq 'del(.[] | .created_at, .updated_at, .request_id)' file1.json | jq --sort-keys .) <(jq 'del(.[] | .created_at, .updated_at, .request_id)' file2.json | jq --sort-keys .)
# Java: remove volatile fields before comparison
ObjectNode node = (ObjectNode) mapper.readTree(file);
node.remove(List.of("created_at", "updated_at", "request_id", "trace_id"));Frequently Asked Questions
What is JSON Lines (JSONL) format and is it easier to compare?
JSON Lines (also called JSONL or NDJSON) stores one JSON object per line with no outer array wrapper. This format is far easier to compare with standard Unix tools because each line is an independent, self-contained JSON object. You can process it with grep or awk and stream line by line without loading the entire file. One caveat: do not reach for sort | diff on the raw lines. That compares them as text, so two lines carrying identical data with their keys in a different order are reported as different — exactly the trap Strategy 1 exists to avoid. Canonicalise first:diff <(jq -cS . a.jsonl | sort) <(jq -cS . b.jsonl | sort). If you have control over your export format (Spark jobs, data pipeline outputs, log files), always prefer JSONL over a single large JSON array for files over 10MB.
How do I compare only specific nested fields in a large JSON without parsing the whole file?
Use jq with a select expression to extract only the fields you need, then pipe to diff:diff <(jq '[.orders[] | {id: .id, status: .status}]' f1.json) <(jq '[.orders[] | {id: .id, status: .status}]' f2.json). In Java, use Jackson's streaming API with parser.skipChildren() to avoid materialising values you don't need. Two things to know about it. It only does anything when the parser is positioned on START_OBJECT or START_ARRAY — the javadoc is explicit that it skips children "iff stream points to START_OBJECT or START_ARRAY" — so calling it on a field name or on a scalar is a no-op, which is usually where people aim it. And it saves memory, not parsing time: the parser still lexes every byte of the file either way, so skipping most of the fields in a large document leaves the wall-clock time essentially unchanged.
Can I use Python for large JSON file comparison?
Yes. Python's ijson library provides streaming JSON parsing similar to Jackson's streaming API in Java. For key-based array comparison, pandaswith read_json(lines=True) for JSONL is very efficient. For quick diffs,deepdiff library handles nested JSON comparison with type coercion options. The same principles apply: avoid loading both full files into memory simultaneously, use key-based comparison for object arrays, and prefer JSONL format for large exports.
How do I handle JSON files with duplicate keys?
Duplicate keys in JSON are technically allowed by the spec but produce undefined behaviour — different parsers handle them differently. Python's jsonmodule keeps the last value. Jackson by default keeps the last value. Some parsers throw. There is no switch that normalises them, so do not expect one: what you can do is refuse to process a document that has them. Jackson's JsonParser.Feature.STRICT_DUPLICATE_DETECTION is the right one — it throws a JsonParseException naming the duplicate field, on every read path.DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY is the one usually recommended and it is a poor fit here: it aborts rather than normalising, and it has no effect at all on the readValue(..., Object.class) path used in Strategy 1, where a duplicate passes silently. In practice, duplicate keys almost always indicate a bug in the producer — fail loudly and fix the source rather than working around it.
What is the fastest way to find which array element changed in a 100,000-item JSON array?
If your array elements have a unique identifier field, the chunked key-based approach is fastest: build a hash map keyed by identifier for both arrays (streaming, O(n) memory), then compare values by key. This is O(n) time and O(n) memory — unavoidable for this problem. If elements have no identifier, you must use LCS (Longest Common Subsequence), which is O(n²) in both time and space. Time is rarely what stops you — the wall is memory: a full n×n matrix at 10,000 items is already in the hundreds of megabytes, and it quadruples every time the array doubles. In that case, sort both arrays by a canonical representation first, then compare positionally — much faster than LCS and gives good results when the arrays are mostly ordered similarly.