How to Version JSON Data in an Application Database

Open inAnthropic

The most reliable way to version JSON data in an application database is to store it in a database that versions every write for you. In TerminusDB, each insert, update and delete becomes an immutable commit — with an author, a timestamp and a message — so you can view the full history of a document, diff any two states, branch for safe changes, and roll back, without writing any versioning code yourself.

Your options for versioning JSON

Three patterns cover nearly every approach to versioning JSON in an application:

  • Audit columns or history tables — you copy old versions into a shadow table or a previous_version column yourself. It works, but every query and migration carries the extra schema, and computing diffs is your problem.
  • Event sourcing — you store every change as an event and rebuild state by replaying them. Powerful, but you now maintain an event store, projections and replay logic on top of the database.
  • A versioned database — the database keeps the history for you. TerminusDB takes this path: writes are commits on an append-only store, so versioning is a property of the storage engine rather than your application code.

This guide walks through the third option end to end — create a database, version a JSON document, inspect its history, and diff two versions. Every curl command runs as-is against a local TerminusDB.

Try the examples on this page

Start TerminusDB locally:

docker run --rm -p 6363:6363 terminusdb/terminusdb-server

Different server?

1. Create a database

A TerminusDB database is a versioned container for JSON documents — the rough equivalent of a Git repository.

Loading…

Expected output:

{
  "@type": "api:DbCreateResponse",
  "api:status": "api:success"
}

2. Insert a JSON document — your first commit

Every write to TerminusDB is a commit. The raw_json=true parameter accepts any valid JSON document — no schema required — and the author and message parameters are recorded in the commit log, exactly like git commit -m.

Loading…

Expected output:

[
  "terminusdb:///data/product/SKU-001"
]

The @id gives the document a stable identity across versions — the response echoes it back as a full IRI. Omit @id and the server mints a random ID instead; the versioning works the same either way.

3. Update the document — a second version

Replace the document with a new state. TerminusDB stores the new version alongside the old one rather than overwriting it.

Loading…

Expected output:

[
  "product/SKU-001"
]

The document now reads stock: 87, and the previous version (stock: 42) remains in history — permanently retrievable. Updates return the short @id form rather than the full IRI.

4. Read the commit history

The commit log lists every version of the database, newest first — the equivalent of git log.

Loading…

Example output — one entry per commit, each with author, message, timestamp and the commit's identifier:

Example: JSON
[
  {
    "@type": "ValidCommit",
    "author": "[email protected]",
    "identifier": "rlg1k3q...",
    "message": "Restock SKU-001",
    "timestamp": 1760000000.0
  },
  {
    "@type": "ValidCommit",
    "author": "[email protected]",
    "identifier": "9fhw2a1...",
    "message": "Add first product",
    "timestamp": 1760000001.0
  }
]

A commit identifier can be pinned into any API path as local/commit/<id> for time-travel reads — see Time Travel for the full pattern.

5. Isolate a change on a branch

Branches let you stage JSON changes without touching production state — the equivalent of git branch. Create a reprice branch, then write to it by putting the branch name in the API path.

Loading…

Expected output:

{
  "@type": "api:BranchResponse",
  "api:status": "api:success"
}
Loading…

Expected output:

[
  "product/SKU-001"
]

The price change lives only on reprice. main still shows the old price.

6. Diff two versions

The diff endpoint compares any two states — two branches, two commits, or a branch and a commit — and returns the precise field-level changes.

Loading…

Expected output:

[
  {
    "@id": "terminusdb:///data/product/SKU-001",
    "price": {
      "@after": 179.99,
      "@before": 149.99,
      "@op": "SwapValue"
    }
  }
]

The diff is a structural operation, not a text diff: it reports price as a SwapValue from 149.99 to 179.99 and says nothing about fields that did not change. before_data_version and after_data_version also accept commit IDs as "commit:<id>" — the format returned in the TerminusDB-Data-Version response header on every write.

7. Same workflow in TypeScript

The JavaScript client exposes the same operations — insert, update, history, branch and diff — as method calls.

TYPESCRIPT
Run locally
// Insert — creates the first commit
  await client.addDocument(
    { "@id": "product/SKU-001", name: "Mechanical Keyboard", price: 149.99, stock: 42 },
    { raw_json: true },
    "",
    "Add first product",
  )

  // Update — creates a second commit; the old version is retained
  await client.updateDocument(
    { "@id": "product/SKU-001", name: "Mechanical Keyboard", price: 149.99, stock: 87 },
    { raw_json: true },
    "",
    "Restock SKU-001",
  )

  // History — one entry per commit, with author, message and timestamp
  const log = await client.getCommitsLog(0, 5)
  console.log("commits:", log.length)

  // Branch — isolate a price change from main
  await client.branch("reprice")
  client.checkout("reprice")
  await client.updateDocument(
    { "@id": "product/SKU-001", name: "Mechanical Keyboard", price: 179.99, stock: 87 },
    { raw_json: true },
    "",
    "Reprice SKU-001",
  )

  // Diff — compare the branch to main, field by field
  const diff = await client.getVersionDiff("main", "reprice")
  console.log(JSON.stringify(diff, null, 2))

8. Clean up

Delete the demo database — including its history — when you are done.

Loading…

What you get for free

Versioning JSON in TerminusDB brings properties that hand-rolled approaches rarely reach: provenance on every commit (author, message, timestamp — a built-in audit trail), time-travel reads at any commit identifier, three-way merges via POST /api/apply that report conflicts instead of silently overwriting, content-addressed deduplication that keeps long histories compact, and clone/push/pull between instances. The full model is covered in A Graph Database with Git-like Version Control.

See also

Was this helpful?