← Concepts & practices
Choice Data and persistence

Isolation and concurrent reads

A fresh read and a consistent read are not the same promise.

Transaction T1 reads the last available unit. Transaction T2 reserves it and commits before T1 reads again. Read committed gives each statement a fresh committed view; a snapshot keeps T1’s view stable; serializable rejects a conflicting decision rather than letting stale work quietly win.

The judgment to keep

Choose isolation from the observation or invariant you need: state whether a read may change, whether it must be one as-of view, and what happens when a concurrent write conflicts.

TypeScriptGo Choose the visibility your claim requires.
Start with one inventory row

Concurrency changes what “the value” means.

Inventory starts with one available unit at version 1. T1 begins a transaction and reads available = 1. Before T1 reads again, T2 reserves that unit and commits version 2.

Now T1’s next observation depends on its isolation choice. The row has a new committed value, but T1 may still be entitled to the value from its starting view. If T1 acts on that view, the database also needs a rule for the conflict.

Fixed scheduleT1 reads · T2 commits · T1 reads or writes

The schedule is deliberately small so visibility and conflict are not hidden inside a whole application.

Initial state
available = 1, version 1
T2’s change
Reserve one unit and commit version 2
T1 report
Read the same value twice and notice whether it changes
T1 reservation
Reserve with a conditional update and observe its rejection or a conflict abort
Compare the guarantees

There are two questions: what can T1 read, and can T1 safely commit?

Use the same schedule for both questions. A report asks for a consistent view. A reservation asks whether a decision based on the view may change the state. The answer to the first does not automatically answer the second.

Read committedFresh per statement

T1 may read 1, then 0. The level alone would let a write from the first read overwrite T2’s reservation; the conditional update (WHERE available > 0) is what rejects it.

SnapshotRepeatable transaction view

T1 may read 1, then 1. A stale reservation needs conflict detection or it could act on an old fact.

SerializableReject conflicting schedules

T1 keeps a coherent view, and the engine also tracks what T1 read. A write skew across two rows is aborted so the result matches some serial order.

In the inventory schedule, snapshot and serializable agree: T1 and T2 write the same row, and snapshot catches that. They part ways when two transactions write different rows. Alice and Bob are both on call, and at least one must stay. Each transaction counts two doctors on call, then takes only its own doctor off. Snapshot sees no row written twice and commits both, leaving nobody on call. That is a write skew. Serializable notices that T2 read Alice’s row after T1 changed it, and aborts T2.

Why “latest” can still be inconsistentPer-statement freshness is not one snapshot

Imagine a report reads a count from one table, another transaction changes related rows, and the report reads a second table. Read committed may give each statement a current answer while the pair never existed together at one point in time. Choose a snapshot-style view when the report’s meaning is “as of one observed state.”

Hold the schedule fixed

Run the same interleaving through different isolation choices.

The lab runs the displayed TypeScript model. First run the report question under read committed, then switch to snapshot. Then ask the reservation question: a repeatable read is not permission to overwrite a newer version. Finally run the on-call question under snapshot and serializable to see the one schedule where they differ.

Visibility lab

What can T1 see after T2 commits?

Keep the schedule fixed. Change the isolation choice or the question T1 is asking.

Current scheduleLatest value per statement

Each statement reads the latest committed value. T1 reads available inventory twice while T2 reserves the unit between reads.

Evidence appears after a run.

Start with read committed and a report read twice. Then compare the same schedule with a snapshot.

This is one deterministic teaching schedule, not a database engine. It does not model every lock, predicate conflict, MVCC detail, retry policy, or isolation-level spelling. Check the database documentation before relying on a label.

Read the conflict as evidenceAbort, retry, or show a fresh value

When a snapshot or serializable transaction rejects T1’s stale write, that is not a mysterious failure. It is the isolation boundary refusing to let two incompatible histories commit silently. The caller must decide whether to retry from a fresh view, merge, or ask the user.

Separate the two comparisons

Hold the schedule steady. Change the language.

The schedule gives T1 a first read, lets T2 commit, then gives T1 its next observation.

TypeScriptReading
inventory.ts · visibility
function read(view: InventoryState, snapshot: InventoryState | null): number {
	return (snapshot ?? view).available;
}

function writerReservesOne(state: InventoryState): void {
	if (state.available < 1) throw new Error('T2 found no available unit');
	state.available -= 1;
	state.version += 1;
}

export function runSchedule(isolation: Isolation, question: Question = 'report'): IsolationRun {
	const initial = copyState(initialInventory);
	const live = copyState(initial);
	const snapshot = isolation === 'read-committed' ? null : copyState(live);
	const view = isolation === 'read-committed' ? 'latest statement' : 'transaction snapshot';
	const firstRead = read(live, snapshot);

	writerReservesOne(live);
	const afterWriter = copyState(live);
	const secondRead = read(live, snapshot);

	if (question === 'report') {
		return {
			isolation,
			question,
			view,
			initial,
			afterWriter,
			firstRead,
			secondRead,
			writerCommitted: true,
			readerCommitted: true,
			outcome: firstRead === secondRead ? 'repeatable read' : 'non-repeatable read',
			explanation:
				firstRead === secondRead
					? 'T1 read from one transaction view even though T2 committed a change in the live state.'
					: 'Each T1 statement saw the latest committed value, so the same read changed during the transaction.'
		};
	}

	// Read committed: T1 writes with UPDATE … WHERE available > 0, which reads the latest row.
	const readerCommitted =
		isolation === 'read-committed'
			? secondRead >= 1
			: live.version === (snapshot?.version ?? live.version);
	return {
		isolation,
		question,
		view,
		initial,
		afterWriter,
		firstRead,
		secondRead,
		writerCommitted: true,
		readerCommitted,
		outcome: readerCommitted
			? 'reservation committed'
			: isolation === 'read-committed'
				? 'conditional update rejected reservation'
				: 'conflicting write aborted',
		explanation: readerCommitted
			? 'The conditional update found a unit and reserved it.'
			: isolation === 'read-committed'
				? 'Read committed alone would let T1 write from its stale first read. T1 writes with a conditional update (WHERE available > 0); that statement saw the committed 0 and changed no row.'
				: 'T1 tried to commit from an older view. Version/conflict detection rejected the stale decision.'
	};
}
GoAlongside
inventory.go · visibility
func read(view InventoryState, snapshot *InventoryState) int {
	if snapshot != nil {
		return snapshot.Available
	}
	return view.Available
}

func writerReservesOne(state *InventoryState) {
	state.Available--
	state.Version++
}

func RunSchedule(isolation Isolation, question Question) IsolationRun {
	initial := copyState(initialInventory)
	live := copyState(initial)
	var snapshot *InventoryState
	view := "latest statement"
	if isolation != ReadCommitted {
		value := copyState(live)
		snapshot = &value
		view = "transaction snapshot"
	}
	firstRead := read(live, snapshot)

	writerReservesOne(&live)
	afterWriter := copyState(live)
	secondRead := read(live, snapshot)

	if question == Report {
		repeatable := firstRead == secondRead
		explanation := "Each T1 statement saw the latest committed value, so the same read changed during the transaction."
		outcome := "non-repeatable read"
		if repeatable {
			explanation = "T1 read from one transaction view even though T2 committed a change in the live state."
			outcome = "repeatable read"
		}
		return IsolationRun{Isolation: isolation, Question: question, View: view, Initial: initial, AfterWriter: afterWriter, FirstRead: firstRead, SecondRead: secondRead, WriterCommitted: true, ReaderCommitted: true, Outcome: outcome, Explanation: explanation}
	}

	readerCommitted := false
	outcome := "conflicting write aborted"
	explanation := "T1 tried to commit from an older view. Version/conflict detection rejected the stale decision."
	if isolation == ReadCommitted {
		// Read committed: T1 writes with UPDATE … WHERE available > 0, which reads the latest row.
		readerCommitted = secondRead >= 1
		outcome = "conditional update rejected reservation"
		explanation = "Read committed alone would let T1 write from its stale first read. T1 writes with a conditional update (WHERE available > 0); that statement saw the committed 0 and changed no row."
	}
	if readerCommitted {
		outcome = "reservation committed"
		explanation = "The conditional update found a unit and reserved it."
	}
	return IsolationRun{Isolation: isolation, Question: question, View: view, Initial: initial, AfterWriter: afterWriter, FirstRead: firstRead, SecondRead: secondRead, WriterCommitted: true, ReaderCommitted: readerCommitted, Outcome: outcome, Explanation: explanation}
}

Both examples keep the same initial version, T1/T2 interleaving, and outcomes. The language changes how the state and view are represented; it does not change the isolation contract.

Copy the complete examplesDeterministic standard-library model

These files model the observable schedule without claiming to be a database driver. Apply the same questions to the isolation levels your engine actually supports.

TypeScriptnode --experimental-strip-types inventory.ts

Gogo run inventory.go

Know what the choice leaves open

Isolation is engine-specific and workload-dependent.

This lesson establishes the difference between a latest-per-statement view, a transaction snapshot, and a conflict-rejecting serializable schedule in one bounded example. It does not define every database’s exact behavior, guarantee a particular lock or MVCC implementation, or tell you whether a retry is safe.

Check the engine documentation for supported levels, phantom reads, predicate locks, write conflicts, deadlocks, long-running snapshots, and retry errors. Atomicity still matters: once the isolation choice produces a committed transaction, its grouped writes should publish together.

Build UIs?See where this shows up in your components.

Choose from the read and the write.

A read-only report may need a stable snapshot without needing serializable writes. A seat reservation or account transfer may need conflict detection, a lock, or a serializable transaction. State the invariant and the recovery policy before turning up the isolation level.

Practice the choice

Match the isolation to the observation you need.

A dashboard joins several tables and must describe one coherent state while other transactions write. Which first choice matches that read requirement?

Decision point

A dashboard must be internally consistent as of one point in time.

Other transactions continue writing while the report runs. Which first choice matches that read requirement?

Leave a visibility note

Make the next overlap explainable.

Record what T1 needs to observe, the schedule that can challenge it, the isolation level chosen, the conflict or anomaly you expect, and the recovery after an abort. A label without an example is difficult to verify.

Why
A dashboard must describe one coherent state, and a reservation must not act on a stale read.
What
A snapshot-style read for the report; a conditional update or serializable transaction for the reservation.
Constraint
Read committed can change between statements, snapshot can stay stable but old, and only serializable aborts a write skew across two rows.
Fallback
When a snapshot or serializable transaction aborts, retry from a fresh view, merge, or ask the user.
Reconsider when
The engine, its supported levels, the invariant, or the write rate changes, or aborts and deadlocks start to cost more than they protect.

A choice note to adapt to your own concurrent reads. Nothing here is saved to an account.

Explore more concepts & practices →