---
title: 'Multi-Version Concurrency Control'
source: 'https://academia.sh/en/courses/database-administration/multi-version-concurrency-control'
course: 'Relational Database Administration'
language: en
updated: '2026-08-23T07:00:41+00:00'
license: 'CC BY-SA 4.0'
---

# Multi-Version Concurrency Control

The difference between lock-based and version-based concurrency control, measuring whether the same read blocks the writer under two log modes, building the version chain and visibility rule through a model, when old versions can be removed, and the conflicts multi-versioning does not remove.

At the end of the previous lesson a constraint was left standing: a checkpoint cannot
overwrite pages currently visible to an in-progress read. A similar observation had been
made in the Advanced SQL course — a read inside a transaction was unaffected by changes
committed outside it. Both observations point to the same mechanism, and that
mechanism has not been built yet.

The question is this: if a row is being updated while a transaction is reading that same
row, what does the read see? There are two consistent answers. Either the reader makes
the writer wait, or the row's old state is kept somewhere. This lesson takes up the
second path and measures its difference from the first.

## Two Approaches

**Lock-based control** provides consistency by waiting. A transaction reading a row
holds a shared lock on that row; a transaction wanting to change it requests an
exclusive lock and waits until the read finishes. The result is correct, but the read
and the write serialize each other.

**Multi-version concurrency control** provides consistency by keeping. When a row is
updated, its old state is not deleted; a new version is added, and each version carries
which transaction created it and which transaction invalidated it. A reading transaction
picks the version that matches its own starting point. The writer does not wait, the
reader does not wait; the two sides look at different versions.

This is what the **snapshot** concept introduced in the Advanced SQL course corresponds
to. A snapshot is not a copied database; it is a criterion that determines which
versions are seen.

## The Measured Difference

The difference can be observed. The run below executes the same scenario under two log
modes: a reader transaction opens and reads, then a writer on a separate connection
tries to update the same table.

```sh
rm -f e.db e.db-wal e.db-shm e.db-journal
cat > blocking.mjs <<'JS'
import { DatabaseSync } from 'node:sqlite';
import { rmSync } from 'node:fs';

function attempt(mode) {
  for (const suffix of ['', '-wal', '-shm', '-journal']) rmSync('e.db' + suffix, { force: true });
  const setup = new DatabaseSync('e.db');
  setup.exec(`PRAGMA journal_mode = ${mode}`);
  setup.exec('CREATE TABLE loan(loan_id INTEGER PRIMARY KEY, member_id INT, return_date TEXT)');
  setup.exec("INSERT INTO loan VALUES (1,4,NULL),(2,4,NULL),(3,7,'2024-03-09')");
  setup.close();

  const reader = new DatabaseSync('e.db');
  const writer = new DatabaseSync('e.db');
  const open = (conn) => conn.prepare('SELECT count(*) AS c FROM loan WHERE return_date IS NULL').get().c;

  reader.exec('BEGIN');                        // the reader's transaction stays open
  const before = open(reader);
  let writeResult;
  try { writer.exec("UPDATE loan SET return_date = '2024-03-20' WHERE loan_id = 1"); writeResult = 'succeeded'; }
  catch (err) { writeResult = 'blocked: ' + err.message.split('\n')[0]; }
  const after = open(reader);
  reader.exec('COMMIT');
  const afterCommit = open(reader);
  reader.close(); writer.close();
  console.log(`[${mode}] reader inside transaction: ${before} → ${after} | writer: ${writeResult} | after commit: ${afterCommit}`);
}
attempt('delete');
attempt('wal');
JS
node blocking.mjs
```

```
[delete] reader inside transaction: 2 → 2 | writer: blocked: database is locked | after commit: 2
[wal] reader inside transaction: 2 → 2 | writer: succeeded | after commit: 1
```

What the reader sees does not change across the transaction in either case: 2 → 2.
Isolation held under both arrangements. What changed is how it was achieved.

Under the rollback journal, the writer never got anywhere: the database is locked. A
write cannot happen before the read finishes. Under the write-ahead log, the writer
finished its work and committed; the reader kept seeing the old value regardless, and
only saw the new value (1) once its own transaction ended. Neither side waited.

This observation confirms multi-versioning's **observed property**: the reader and the
writer do not block each other. How that property is actually delivered varies by
engine; in the tool used here, the path is not per-row version chains but snapshots
defined through the log. The mechanism itself will be built through a model in the next
section.

## The Version Chain and the Visibility Rule

In an engine that keeps versions per row, each version carries two extra fields: the
number of the transaction that **created** it and the number of the transaction that
**deleted** it. If the deleted field is empty, the version is still valid. An update
does two things within a single transaction: it fills in the current version's deleted
field, and it appends a new version. A delete does only the first of these.

The visibility rule is one sentence: a version is visible in a snapshot if its
**creating transaction has committed** and its **deleting transaction has not
committed**. The model below runs the rule. Real engines' rules are more elaborate —
changes a transaction's own uncommitted work has not seen yet, aborted transactions,
and subtransactions are handled separately — but the core is the same.

```sh
cat > version.mjs <<'JS'
// Model: row versions and the visibility rule. Not the actual engine.
class Store {
  constructor() { this.versions = []; this.committed = new Set(); this.nextTxn = 10; }
  beginTxn() { const t = this.nextTxn; this.nextTxn += 10; return t; }
  commit(t) { this.committed.add(t); }
  snapshot() { return new Set(this.committed); }              // the committed transactions at that moment
  insert(t, rowId, value) { this.versions.push({ rowId, value, created: t, deleted: null }); }
  update(t, rowId, value) {
    const old = this.versions.find((s) => s.rowId === rowId && s.deleted === null);
    if (old) old.deleted = t;
    this.versions.push({ rowId, value, created: t, deleted: null });
  }
  visible(s, snap) { return snap.has(s.created) && (s.deleted === null || !snap.has(s.deleted)); }
  read(rowId, snap) {
    const s = this.versions.find((v) => v.rowId === rowId && this.visible(v, snap));
    return s ? s.value : '(none)';
  }
  reclaimable(snapshots) {                                    // versions no snapshot can see
    return this.versions.filter((s) => snapshots.every((a) => !this.visible(s, a)));
  }
}

const d = new Store();
const t1 = d.beginTxn();                          // the transaction that creates the loan record
d.insert(t1, 501, 'return_date = NULL'); d.commit(t1);

const readerSnap = d.snapshot();                  // the long read starts here
const t2 = d.beginTxn();
d.update(t2, 501, "return_date = '2024-03-20'"); d.commit(t2);
const newSnap = d.snapshot();
const t3 = d.beginTxn();
d.update(t3, 501, "return_date = '2024-03-21'"); d.commit(t3);
const newestSnap = d.snapshot();

console.log('version chain (row 501):');
for (const s of d.versions)
  console.log(`  created=${s.created}  deleted=${s.deleted ?? '—'}  ${s.value}`);
console.log('reader\'s snapshot       →', d.read(501, readerSnap));
console.log('second snapshot         →', d.read(501, newSnap));
console.log('newest snapshot         →', d.read(501, newestSnap));
console.log('reclaimable versions while the reader is open :',
            d.reclaimable([readerSnap, newestSnap]).length);
console.log('after the reader closes                        :',
            d.reclaimable([newestSnap]).length);
JS
node version.mjs
```

```
version chain (row 501):
  created=10  deleted=20  return_date = NULL
  created=20  deleted=30  return_date = '2024-03-20'
  created=30  deleted=—  return_date = '2024-03-21'
reader's snapshot       → return_date = NULL
second snapshot         → return_date = '2024-03-20'
newest snapshot         → return_date = '2024-03-21'
reclaimable versions while the reader is open : 1
after the reader closes                        : 2
```

Three versions of the same loan record sit in the store at the same time, and three
separate snapshots each see a different one of them. No waiting anywhere; every reader
finds the version that matches its own criterion.

One consequence of this arrangement is that an `UPDATE` statement is not physically an
update. Even an update that changes a single byte of a single column writes a full new
copy of the row. This is also behind the index cost the Advanced SQL course stated as
"the same cost applies to an `UPDATE` that touches a key column": the new version sits
in a new place, and the index entries pointing to that row have to be updated too.

## When Versions Can Go Away

The last two lines of the model show the actual administrative problem. Old versions
cannot be kept forever; a version no longer seen by any snapshot can be removed. The
criterion follows directly: a version **older than the oldest open snapshot** is
reclaimable.

In the model, while the reader's snapshot is open, only one version is reclaimable; once
the reader closes, the count rises to two. A single long read is enough to keep two out
of a three-version chain pinned in place.

The scale of this result matters. A transaction left open for hours keeps the old
versions of **every** update made during that time alive. The table's row count may not
change, yet the space it occupies on disk keeps growing. The previous lesson's
observation that a checkpoint can be delayed by readers is another face of this same
rule.

## The Conflict Multi-Versioning Does Not Remove

The reader and the writer not blocking each other does not mean every conflict is gone.
The run below shows two points together: two writers conflicting, and an open read
stalling a checkpoint.

```sh
rm -f conflict.db conflict.db-wal conflict.db-shm
cat > blocking2.mjs <<'JS'
import { DatabaseSync } from 'node:sqlite';
import { rmSync } from 'node:fs';
for (const suffix of ['', '-wal', '-shm']) rmSync('conflict.db' + suffix, { force: true });
const setup = new DatabaseSync('conflict.db');
setup.exec('PRAGMA journal_mode = WAL');
setup.exec('CREATE TABLE loan(loan_id INTEGER PRIMARY KEY, member_id INT, return_date TEXT)');
setup.exec(`INSERT INTO loan SELECT n, 1+(n*13)%500, NULL
          FROM (WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n<20000) SELECT n FROM s)`);
setup.exec('PRAGMA wal_checkpoint(TRUNCATE)');
setup.close();

const writerA = new DatabaseSync('conflict.db'), writerB = new DatabaseSync('conflict.db');
writerA.exec('BEGIN IMMEDIATE');
writerA.exec("UPDATE loan SET return_date = '2024-04-01' WHERE loan_id = 1");
try { writerB.exec('BEGIN IMMEDIATE'); writerB.exec("UPDATE loan SET return_date = '2024-04-02' WHERE loan_id = 2"); console.log('two writers: both proceeded'); }
catch (err) { console.log('two writers → second writer:', err.message.split('\n')[0]); }
writerA.exec('COMMIT');
writerA.close(); writerB.close();

// does a long read delay the checkpoint
const reader = new DatabaseSync('conflict.db'), writer = new DatabaseSync('conflict.db');
writer.exec('PRAGMA wal_autocheckpoint = 0');
reader.exec('BEGIN');
reader.prepare('SELECT count(*) AS c FROM loan').get();
for (let i = 1; i <= 3000; i++) writer.exec(`UPDATE loan SET return_date='2024-05-01' WHERE loan_id=${1+(i*37)%20000}`);
console.log('checkpoint while the reader is open :', JSON.stringify(writer.prepare('PRAGMA wal_checkpoint(PASSIVE)').get()));
reader.exec('COMMIT');
console.log('checkpoint once the reader is done  :', JSON.stringify(writer.prepare('PRAGMA wal_checkpoint(PASSIVE)').get()));
reader.close(); writer.close();
JS
node blocking2.mjs
```

```
two writers → second writer: database is locked
checkpoint while the reader is open : {"busy":0,"log":3532,"checkpointed":0}
checkpoint once the reader is done  : {"busy":0,"log":3532,"checkpointed":3532}
```

The first line shows the boundary: when two transactions try to write at the same time,
the second one cannot proceed. In the tool used here, this limit applies to the whole
database; in engines that resolve conflicts at row granularity, the limit applies only
between transactions updating the **same row**. Either way, the principle is the same:
versions free up reading, but two writes changing the same data still queue up. This is
why the Advanced SQL course's deadlock discussion still holds.

The second and third lines close the point the previous lesson left open. While the
reader's transaction was open, 3,532 frames had accumulated in the log, and the
checkpoint was able to process **zero** of them. Once the reader finished its
transaction, the same call processed all 3,532 frames. The reason is singular: those
frames carry the versions the reader can see, and they cannot be overwritten before the
reader is done.

## Summary

- Lock-based control provides consistency by waiting; multi-version control provides it
  by keeping the row's old state.
- In the measurement, the writer was blocked under the rollback journal; under the
  write-ahead log, the same writer completed and the reader kept seeing the old value
  throughout its transaction.
- Each version carries the transactions that created and deleted it; a version is
  visible in a snapshot if its creator has committed and its deleter has not.
- A version can be removed only once no open snapshot sees it; a single long read kept
  two of the model's three versions pinned in place.
- Multi-versioning separates reading from writing, not two writes that change the same
  data; an open read also stalls a checkpoint (zero of 3,532 frames could be processed).

## Next Step

Old versions becoming reclaimable does not mean they are reclaimed on their own. A
separate process does the reclaiming, and if that process cannot keep up, a table keeps
growing even when its row count never rises: every update leaves a new version behind,
and the old one stays in place. The next lesson takes up this buildup: measuring dead
rows, reclaiming space, why reclaimed space does not always return to the operating
system, and the balance between the cleanup process and the bloat ratio.
