Detecting Changes in JSON Data — Audit Logs, Diffs, and Change Tracking
Detecting what changed in a JSON document is essential for audit logs, versioning, and syncing. Here's how to track JSON changes with diff algorithms, store change history, and...
Change detection in JSON is the foundation of audit trails, collaborative editing, and optimistic concurrency. The goal: given two versions of a document, know precisely what changed, when, and by whom.
Use the JSON Diff Tool to visually compare two JSON documents.
The basic pattern
// Store original, apply changes, diff to get what changed:
async function updateDocument(id, updates) {
const original = await db.find(id);
const updated = { ...original, ...updates };
const changes = diffJson(original, updated);
await db.save(updated);
await auditLog.record({ id, changes, timestamp: new Date(), user: currentUser });
return updated;
}
Generating a change record
function diffJson(before, after, path = '') {
const changes = [];
if (typeof before !== 'object' || typeof after !== 'object' ||
before === null || after === null) {
if (before !== after) {
changes.push({ path, before, after, op: 'replace' });
}
return changes;
}
// Detect removed keys
for (const key of Object.keys(before)) {
const fullPath = path ? `${path}.${key}` : key;
if (!(key in after)) {
changes.push({ path: fullPath, before: before[key], op: 'remove' });
} else {
changes.push(...diffJson(before[key], after[key], fullPath));
}
}
// Detect added keys
for (const key of Object.keys(after)) {
if (!(key in before)) {
const fullPath = path ? `${path}.${key}` : key;
changes.push({ path: fullPath, after: after[key], op: 'add' });
}
}
return changes;
}
const before = { name: 'Alice', age: 30, role: 'user' };
const after = { name: 'Alice', age: 31, email: 'alice@example.com' };
diffJson(before, after);
// [
// { path: 'age', before: 30, after: 31, op: 'replace' },
// { path: 'role', before: 'user', op: 'remove' },
// { path: 'email', after: 'alice@example.com', op: 'add' }
// ]
Audit log schema
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL, -- 'user', 'order', 'product'
entity_id VARCHAR(100) NOT NULL,
operation VARCHAR(10) NOT NULL, -- 'create', 'update', 'delete'
changes JSONB, -- the diff
before_data JSONB, -- snapshot before
after_data JSONB, -- snapshot after
changed_by VARCHAR(100),
changed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON audit_logs (entity_type, entity_id);
CREATE INDEX ON audit_logs (changed_at);
Storing both the diff (changes) and snapshots (before_data, after_data) lets you:
- Quickly see what changed (read the diff)
- Reconstruct any historical state (replay snapshots)
- Generate human-readable changelogs
Storing change history in PostgreSQL
// Using PostgreSQL with JSONB:
async function updateWithAudit(entityType, entityId, updates, userId) {
const before = await db.query(
`SELECT data FROM ${entityType}s WHERE id = $1`,
[entityId]
);
const beforeData = before.rows[0].data;
const afterData = { ...beforeData, ...updates };
await db.transaction(async (trx) => {
await trx.query(
`UPDATE ${entityType}s SET data = $1 WHERE id = $2`,
[afterData, entityId]
);
await trx.query(
`INSERT INTO audit_logs
(entity_type, entity_id, operation, changes, before_data, after_data, changed_by)
VALUES ($1, $2, 'update', $3, $4, $5, $6)`,
[entityType, entityId, JSON.stringify(diffJson(beforeData, afterData)),
beforeData, afterData, userId]
);
});
return afterData;
}
Event sourcing approach
Instead of storing the current state and diffs separately, event sourcing stores only the events (changes):
// Event store — append only:
const eventStore = {
async append(streamId, eventType, data) {
await db.query(
`INSERT INTO events (stream_id, event_type, data, created_at)
VALUES ($1, $2, $3, NOW())`,
[streamId, eventType, JSON.stringify(data)]
);
},
async getStream(streamId) {
const result = await db.query(
`SELECT * FROM events WHERE stream_id = $1 ORDER BY created_at`,
[streamId]
);
return result.rows;
},
};
// Usage:
await eventStore.append(`user:${userId}`, 'UserEmailChanged', {
before: 'old@example.com',
after: 'new@example.com',
});
await eventStore.append(`user:${userId}`, 'UserRoleChanged', {
before: 'viewer',
after: 'editor',
});
// Reconstruct state by replaying events:
async function getUserState(userId) {
const events = await eventStore.getStream(`user:${userId}`);
return events.reduce((state, event) => {
switch (event.event_type) {
case 'UserEmailChanged':
return { ...state, email: event.data.after };
case 'UserRoleChanged':
return { ...state, role: event.data.after };
default:
return state;
}
}, {});
}
Detecting concurrent modifications (optimistic concurrency)
// Add a version field to detect conflicts:
async function updateWithVersion(id, updates, expectedVersion) {
const result = await db.query(
`UPDATE documents
SET data = $1, version = version + 1
WHERE id = $2 AND version = $3
RETURNING *`,
[{ ...updates }, id, expectedVersion]
);
if (result.rows.length === 0) {
throw new Error('Conflict: document was modified by another process');
}
return result.rows[0];
}
// Client sends the version it last read:
const doc = await getDocument(id); // Returns { data: {...}, version: 5 }
const updated = await updateWithVersion(id, changes, doc.version);
// If version changed to 6 between read and write, throws conflict error
Change notifications with webhooks
// Emit change events that external systems can subscribe to:
async function updateDocument(id, changes) {
const before = await db.find(id);
const after = { ...before, ...changes };
const diff = diffJson(before, after);
await db.save(after);
// Notify webhooks:
await webhookQueue.enqueue({
event: 'document.updated',
payload: {
id,
changes: diff,
timestamp: new Date().toISOString(),
},
});
return after;
}
MongoDB change streams
MongoDB has built-in change detection:
const changeStream = db.collection('users').watch([
{ $match: { operationType: { $in: ['update', 'replace'] } } },
]);
for await (const change of changeStream) {
console.log('Document changed:', change.documentKey._id);
console.log('Updated fields:', change.updateDescription?.updatedFields);
console.log('Removed fields:', change.updateDescription?.removedFields);
}
Related tools
- JSON Diff Tool — compare JSON documents visually
- Compare JSON Objects — deep equality and diff
- JSON Patch RFC 6902 — operation-based JSON updates
Related posts
- Comparing JSON Structurally (Not Just as Strings) — Two JSON documents can be byte-different and semantically identical. Or byte-ide…
- How to Compare JSON Objects — Deep Equality and Diff in JavaScript and Python — Comparing JSON objects with == won't work for deep equality. Here's how to deep-…
- JSON API Response Format — Structuring REST API Responses — A consistent JSON response format makes APIs predictable and easier to consume. …
- JSON Diff Tool — Compare Two JSON Objects and Find Differences — A JSON diff tool compares two JSON structures semantically, not textually. It fi…
- JSON Patch (RFC 6902) — Operation-Based JSON Document Updates — JSON Patch (RFC 6902) defines a sequence of operations (add, remove, replace, mo…
Related tool
Compare two JSON objects structurally with field-by-field diff.
Written by Mian Ali Khalid. Part of the Data & Format pillar.