systems · distributed ·

Consistency, from one copy to many

Consistency looks like a wall of jargon with no organising idea: linearizable, causal, serializable, eventual, ACID, CAP. There is an organising idea, and it is one sentence long. With a single copy of your data there is exactly one order of operations and every reader sees it. Every model below is a rule about which other orders you are willing to let a reader see once there is more than one copy.

  • consistency
  • linearizability
  • isolation
  • replication
  • consensus
  • databases
  • cap

Start where there is no problem

A single register on a single machine. Operations arrive, the machine applies them one at a time, and that is the end of it:

let x = 0
const write = (v) => { x = v }
const read  = () => x

There is no consistency question here, and it is worth being precise about why: every operation meets the same bytes. That gives you a single total order for free, and every reader necessarily sees that order, because there is nothing else to see. Anomalies are not prevented — they are unrepresentable.

So the whole subject only begins when you make a second copy. Which you will, for exactly three reasons, and it is worth knowing which one is driving you because they buy different things:

  • Survival. One machine dies and takes your data with it. Copies are the only defence.
  • Latency. This one is physics, not engineering. Light in fibre travels about 200,000 km/s. London to Sydney is roughly 17,000 km, so 85 ms one way and about 170 ms for a round trip — and that is the floor, before routing, queuing or processing. If a Sydney reader must reach London to read, no protocol will get them under it. A local copy is the only way out.
  • Throughput. One machine has a ceiling. Copies raise the read ceiling immediately, and the write ceiling only if you shard, which is a different problem that interacts badly with this one.
What a second copy buys and what it costsONE COPYthe registerEvery operation meets the same bytes, so there is exactly one orderand every reader sees it. There is no consistency question to ask.There is also one machine: lose it and you have lost everything, andevery reader on earth pays the round trip to reach it.MANY COPIESABCSurvives a machine. Serves a reader from nearby. And now a write anda read can touch different copies, so the single order is gone — whatis left is a *set* of orders the system might show you.Every consistency model is a rule narrowing that set.London → Sydney is 17,000 km. In fibre, light does ~200,000 km/s: 85 ms one way, 170 ms there and back. No protocol beats that.
The moment there are two copies, a write and a read can touch different ones. The single order is gone; what is left is a set of orders the system might show you.

And there is the cost. Copies do not merely risk staleness; they destroy the single order that made the one-copy version obviously correct. A write lands on A. A read is served by B. There is now no fact of the matter about what happened first unless something establishes one — and establishing one costs at least a round trip to whoever else has a say.

The one idea: a model is a set of admissible orders

Here is the sentence the rest of this post is deduction from.

A history is what an outside observer records: a set of operations, each with the instant it was called and the instant it returned. A consistency model is a rule saying which histories are legal. Strong models admit few histories and cost coordination. Weak models admit many and cost you the ability to reason.

That reframing does real work immediately, because it tells you what an operationis from outside: not an instant but an interval. You called it at one time; you heard back at another. What happened in between is unknowable. Two operations whose intervals overlap are concurrent, and concurrency is where all the freedom — and all the confusion — lives.

Concurrency is not an anomalytime →writerwrite(1)reader-aread → 0reader-bread → 1Both reads overlap the write, so the ● points can be placed in an order that explains them: 0 before, 1 after. Legal.
Both reads overlap the write, so a legal order exists: one read before it, one after. A system showing these results is not broken.

Two readers got different answers at almost the same moment — surely that is a bug? No, and this is the single most common misreading in the subject. Linearizability does not say concurrent readers agree. It says there exists some placement of each operation at an instant inside its own interval such that the whole history obeys a single-copy register. When intervals overlap, the placement has freedom, and disagreement is the visible result of that freedom. What is forbidden is disagreement that no placement can explain.

A read that finishes after a write and misses ittime →writerwrite(1)readerread → 0The write finished before the read began. No placement of ● returns 0. There is no ordering; the history is simply illegal.
The write returned before the read was called. No placement of either operation yields 0. This history is not slow or stale — it is impossible under linearizability.

So the definition, in full: a history is linearizable if each operation can be assigned a single instant between its call and its return such that the resulting sequential order is legal for the object. Herlihy and Wing (1990). Every other model on the single-object axis is this definition with something removed.

Judging a history, by hand

The definition is not merely precise, it is decidable. Given a history you can work out on paper whether a legal order exists, and the procedure is short enough to carry in your head — which is what stops arguments about examples.

Lay the operations out as intervals, then repeatedly ask: which operation could come next? Two rules answer it.

  • Real time decides what it can. An operation may go next only if no other operation still waiting finished before this one started. If something finished while this one had not yet begun, that something has already been ordered first by the world, and you have no choice about it.
  • The object decides the rest. A write may always go next, and changes the value standing. A read may go next only if the value it returned is the value currently standing.

Pick any candidate satisfying both, commit to it, and repeat with what remains. Run out of candidates before you run out of operations and you back up and try a different choice. Exhaust every branch and find nothing, and you have proved no legal order exists — the history is not linearizable. That is the entire algorithm, due to Wing and Gong.

Run it on the stale read two figures above and it ends almost immediately. The write finished before the read started, so by the first rule the write must be placed first. The value standing is now 1. The only remaining operation is a read that returned 0, and the second rule forbids it. There are no other branches to try. No legal order — and notice that we established that without knowing anything whatsoever about the database that produced it.

Now the part worth pausing on. Delete the first rule — keep the object’s rules, and keep the requirement that each process’s own operations appear in the order it issued them — and the identical procedure decides sequential consistency instead. The models on this axis are not a list to be memorised. They are one procedure with a constraint added or taken away, which is why the next section can derive the rest of them by subtraction.

The checker, in full60 lines of TypeScript, no dependencies — the same code the unit tests run
// A linearizability checker for a single register.
//
// Pure and total (code_guidelines.md §2): it takes a history and returns either
// an order that explains it or null. No clock, no randomness, no I/O — the
// "time" in a history is data the caller supplies, which is the only way a
// property about time can be tested deterministically.
//
// The algorithm is Wing & Gong's: try to build a linearization one operation at
// a time, backtracking when the choice turns out to be impossible.

/** An operation as an outside observer sees it: it *started* at some instant
 *  and *finished* at a later one, and took effect at some unknowable point in
 *  between. Concurrency is exactly the case where two intervals overlap. */
export type Op = {
  readonly proc: string
  readonly kind: 'write' | 'read'
  readonly value: number
  readonly start: number
  readonly end: number
}

/** The two rules of a sequential register, which is the specification every
 *  linearization is checked against. Everything else in this file is search. */
function step(state: number, op: Op): number | null {
  if (op.kind === 'write') return op.value
  return op.value === state ? state : null // a read must return what is there
}

function search(pending: readonly Op[], state: number, acc: readonly Op[]): readonly Op[] | null {
  if (pending.length === 0) return acc

  // An operation may go next only if no other pending operation *finished*
  // before it *started* — that one line is the whole of the real-time
  // constraint, and dropping it is what turns this into a check for sequential
  // consistency instead.
  const earliestEnd = Math.min(...pending.map((o) => o.end))

  for (const op of pending) {
    if (op.start > earliestEnd) continue
    const next = step(state, op)
    if (next === null) continue
    const found = search(pending.filter((o) => o !== op), next, [...acc, op])
    if (found !== null) return found
  }
  return null
}

/** The linearization, if one exists. `null` means no ordering of these
 *  operations both respects real time and obeys the register's specification —
 *  which is precisely what "this history is not linearizable" means. */
export function linearize(history: readonly Op[], initial = 0): readonly Op[] | null {
  return search([...history], initial, [])
}

export const isLinearizable = (history: readonly Op[], initial = 0): boolean =>
  linearize(history, initial) !== null

/** Sequential consistency keeps per-process order but drops the cross-process
 *  real-time constraint, so it is the same search with the one line removed. */
export function isSequentiallyConsistent(history: readonly Op[], initial = 0): boolean {
  const seqSearch = (pending: readonly Op[], state: number, taken: Map<string, number>): boolean => {
    if (pending.length === 0) return true
    for (const op of pending) {
      // Each process's own operations must still be consumed in their issue
      // order; nothing else constrains the interleaving.
      const mine = history.filter((o) => o.proc === op.proc)
      if (mine[taken.get(op.proc) ?? 0] !== op) continue
      const next = step(state, op)
      if (next === null) continue
      const advanced = new Map(taken).set(op.proc, (taken.get(op.proc) ?? 0) + 1)
      if (seqSearch(pending.filter((o) => o !== op), next, advanced)) return true
    }
    return false
  }
  return seqSearch([...history], initial, new Map())
}

What it leaves out: objects other than a register — a queue or a set needs its own step, and that is genuinely the only change; operations that time out, where the interval has no end and the operation may or may not have happened, so a real checker must try both; and performance. The search is exponential in the worst case, and deciding linearizability is NP-hard in general (Gibbons and Korach, 1997), which is why real tools such as Jepsen’s Knossos and Elle work on short histories with aggressive pruning rather than on your production trace.

Where the intuition actually fails: R + W > N

Now let us point the checker at the piece of folklore that most deserves it. Everyone learns the quorum rule: with N replicas, if read quorum plus write quorum exceeds N, every read set overlaps every write set, so reads see the latest write. N=3, R=2, W=2. Strong consistency, allegedly.

It is not, and the counterexample is embarrassingly ordinary — no partition, no crash, no clock skew, nothing exotic at all.

Three replicas, A, B and C, all holding 0. A writer begins writing 1. Writes do not arrive everywhere at once, so picture the instant at which it has reached A and not yet B. That is not a failure; it is simply the middle of a perfectly healthy write.

Reader one asks A and C. That is two replicas, so its quorum is satisfied. A holds version 1, C holds version 0, the higher version wins, and the read returns 1.

Reader two starts after reader one has already returned, and asks B and C. Both still hold version 0. It returns 0.

One read saw the new value. A strictly later read — one that had not even been issued when the first returned — saw the old one. Time went backwards for an outside observer, and by the first rule of the previous section there is no order that explains it: the read returning 1 must follow the write, and the read returning 0 must both follow that read and precede the write.

Quorum overlap guarantees nothing about a write still in flightN = 3 W = 2 R = 2 → R + W > Nthe write has reached A only — it is not finished yetAv1Bv0Cv0reader-a reads {A, C} → highest version wins → 1reader-b reads {B, C} → nothing but v0 → 0reader-b started after reader-a finished, and went backwards. R + W > N guarantees overlap with aCOMPLETED write. An in-flight write has completed nothing, and every write is in flight for a while.The fix is one line: before returning a value, write it back to the replicas you read from (ABD).A read that publishes what it saw cannot be contradicted by the read that follows it.
R + W > N guarantees overlap with a completed write. An in-flight write has completed nothing — and every write is in flight for a while.

Rather than leave that as an argument, the post runs it. A simulated three-replica cluster performs exactly that schedule, the resulting history is handed to the checker from the previous section, and the verdict below is whatever the checker returned when this page was built:

ConfigurationValues observedLinearizable?
quorum read, R+W>N1, 1, 0no
quorum + read repair (ABD)1, 1, 1yes

The first row is not linearizable. It is, however, sequentially consistent — and that is the more useful half of the result. Dropping read repair does not hand you a broken system. It hands you a weaker model, one that is perfectly self-consistent and that nobody wrote down in the design document.

The simulated clusterwhat produced those two histories — 70 lines, no clock, no randomness
// A replicated register, simulated, so that the histories the checker judges
// are produced by a mechanism rather than written by hand.
//
// Pure (code_guidelines.md §2): the cluster is a value passed in and returned,
// there is no clock — "time" is a counter the scenario advances — and no
// randomness, so every history below is reproducible byte for byte.

import type { Op } from './checker.ts'

/** One replica holds a value and the version that stamped it. Versions are what
 *  let a reader pick a winner among disagreeing replicas. */
export type Replica = { value: number; version: number }
export type Cluster = Replica[]

export const cluster = (n: number): Cluster =>
  Array.from({ length: n }, () => ({ value: 0, version: 0 }))

/** Send a value to a chosen set of replicas. A *partial* write — one that
 *  reached fewer than W — is not an error case bolted on afterwards; it is what
 *  every in-flight write looks like, and the reason quorum overlap alone is not
 *  enough. */
export function writeTo(c: Cluster, targets: readonly number[], value: number, version: number): void {
  for (const i of targets) {
    const r = c[i]
    if (r !== undefined && version > r.version) { r.value = value; r.version = version }
  }
}

/** Read from a set of replicas and take the highest version seen.
 *
 *  `repair` is the entire difference between a quorum read and the ABD
 *  algorithm: write the winning value back to the replicas you read from before
 *  returning it, so the value you just showed someone cannot subsequently
 *  un-happen. */
export function readFrom(c: Cluster, targets: readonly number[], repair: boolean): number {
  let best: Replica = { value: 0, version: -1 }
  for (const i of targets) {
    const r = c[i]
    if (r !== undefined && r.version > best.version) best = r
  }
  if (repair && best.version >= 0) writeTo(c, targets, best.value, best.version)
  return best.value
}

export type Scenario = { readonly label: string; readonly history: readonly Op[] }

/** The canonical quorum failure, N=3 W=2 R=2.
 *
 *  A writer is mid-flight: its value has reached one replica. Two reads follow,
 *  the second starting strictly after the first finished, and they disagree —
 *  the later one goes backwards. Set `repair` to run ABD instead and watch the
 *  same schedule become linearizable.
 */
export function partialWrite(repair: boolean): Scenario {
  const c = cluster(3)
  const history: Op[] = []

  // The write is in progress throughout: it has reached replica 0 and not yet
  // replica 1, so its interval spans both reads.
  writeTo(c, [0], 1, 1)
  history.push({ proc: 'writer', kind: 'write', value: 1, start: 0, end: 60 })

  const first = readFrom(c, [0, 2], repair)
  history.push({ proc: 'reader-a', kind: 'read', value: first, start: 10, end: 20 })

  const second = readFrom(c, [1, 2], repair)
  history.push({ proc: 'reader-b', kind: 'read', value: second, start: 30, end: 40 })

  return { label: repair ? 'quorum + read repair (ABD)' : 'quorum read, R+W>N', history }
}

/** A read served by a replica that has not caught up. The write completed
 *  before the read began, so no ordering can explain the stale answer. */
export function staleRead(): Scenario {
  return {
    label: 'async replica, stale read',
    history: [
      { proc: 'writer', kind: 'write', value: 1, start: 0, end: 10 },
      { proc: 'reader', kind: 'read', value: 0, start: 20, end: 30 },
    ],
  }
}

/** Concurrency is not an anomaly. Both reads overlap the write, so a linearizer
 *  is free to place the write between them. */
export function concurrentOk(): Scenario {
  return {
    label: 'overlapping write, disagreeing reads',
    history: [
      { proc: 'writer', kind: 'write', value: 1, start: 0, end: 40 },
      { proc: 'reader-a', kind: 'read', value: 0, start: 5, end: 15 },
      { proc: 'reader-b', kind: 'read', value: 1, start: 20, end: 35 },
    ],
  }
}

/** Each process sees a self-consistent order, but no single order explains
 *  both — the classic separation between sequential consistency (which this
 *  passes) and linearizability (which it fails). */
export function sequentialNotLinearizable(): Scenario {
  return {
    label: 'each process consistent, no global order in real time',
    history: [
      { proc: 'a', kind: 'write', value: 1, start: 0, end: 10 },
      { proc: 'b', kind: 'write', value: 2, start: 20, end: 30 },
      { proc: 'a', kind: 'read', value: 1, start: 40, end: 50 },
    ],
  }
}

So the quorum rule is wrong? No — it is precisely right about a smaller thing than people remember. It guarantees that a read set intersects the write set of every completed write. The counterexample uses a write that has not completed, and for that one the rule promises nothing at all. The error is not in the arithmetic; it is in dropping the word “completed” when repeating it.

The fix is one line, and it is the ABD algorithm (Attiya, Bar-Noy and Dolev, 1995): a read writes back the value it is about to return, before returning it. Then a value that has been shown to anyone is already durable enough that the next read cannot miss it — which is exactly what the second row above shows. The cost is that reads now perform writes, so a read-heavy workload pays write amplification for a property most of those reads did not need. That is the trade, stated honestly, and it is why Dynamo-style stores that skip it are not lying; they are selling something else.

Is this history possible?Judged by the checker from the next section, running here in your browser.
The history under testwriterwrite(1)reader-aread → 1reader-bread → 0
Not linearizable. No ordering of these operations both respects real time and obeys a register. It is, however, sequentially consistent: drop the cross-process real-time rule and an order appears. That gap is the whole difference between the two models.

linearizable: false · sequentially consistent: true

The models, derived rather than listed

Take linearizability and remove one constraint at a time. Each removal has a name, and each admits strictly more histories.

  • Drop real time across processes, keep a single order. That is sequential consistency (Lamport, 1979). Everyone agrees on one order and each process’s own operations appear in the order it issued them — but that order need not match the wall clock. A write that finished an hour ago may be ordered after a read that happened now.
  • Drop the single order; keep it only for causally related operations. Causal consistency. If I read your post and then reply, nobody sees my reply before your post. Two unrelated posts may be seen in either order by different readers. This is the strongest model that stays available under partition (Mahajan et al., 2011) — which makes it far more interesting than its popularity suggests.
  • Drop cross-client promises; keep promises to one client. The session guarantees (Terry et al., 1994): read-your-writes, monotonic reads, monotonic writes, writes-follow-reads. Cheap, and they remove most of what users actually notice.
  • Drop everything except convergence. Eventual consistency: if writes stop, replicas agree. Note what it does not say — no bound on when, and no promise whatsoever before then. “Eventually” is not a latency figure.

If sequential consistency gives everyone one agreed order, why would anyone pay for linearizability? Because of the channel your system cannot see. Two users on a phone call, one clicking “pay” and telling the other to refresh; a service writing to a database and then posting to a queue that another service reads. Sequential consistency permits the second observer to see the world before the write, and the out-of-band message is what makes that visible as a bug. Linearizability is exactly the model that composes with the real world, because real time is the one channel everything shares. It is also why linearizability is local — compose linearizable objects and the result is linearizable — which is not true of sequential consistency.

The other axis, which is not the same axis

Everything above concerns one object. Transactions concern several, and the vocabulary collides badly: the C in ACID means “your invariants hold”, which is your job and not the database’s. The word that matters for transactions is isolation.

Serializability says the outcome equals some serial execution of the transactions. Note what it does not say: which one, or that it has anything to do with real time. A serializable database may legally order a transaction that committed an hour ago after one running now. Strict serializability is serializability plus linearizability — one serial order, and it respects real time. Spanner calls it external consistency.

Two axes, joined at the topONE OBJECT — what a reader may seeMANY OBJECTS — how transactions interleavestrict serializabilityboth axes at once: one order, and it respects real timelinearizabilitysequential consistencycausal consistencysession guarantees (RYW, MR, MW, WFR)eventual consistencyserializabilitysnapshot isolationrepeatable readread committedread uncommittedEach arrow reads "is weaker than". Weaker means more histories are admissible — more things a reader might see.
Two independent axes. Strict serializability is the top of both. Each arrow reads 'is weaker than' — weaker meaning more histories admitted.

The weaker isolation levels are best understood by what they let through, not by their names — the names are historically confused. ANSI SQL defined levels by which anomalies they forbid, Berenson et al. (1995) showed the definitions were ambiguous and did not capture snapshot isolation at all, and Adya (1999) gave the definitions people actually use now. The practical residue: PostgreSQL’s REPEATABLE READ is snapshot isolation, Oracle’s SERIALIZABLE is snapshot isolation too, and only one anomaly separates SI from the real thing.

What can still go wrong at this isolation level?Pick a level, then an anomaly to see the schedule.
invariant: at least one doctor on call
T1  read(on_call) → {alice, bob}
T2  read(on_call) → {alice, bob}
T1  alice off call; COMMIT
T2  bob off call;   COMMIT    ← nobody on call

Snapshot isolation prevents four of the five and permits the fifth. That is not a gap in an implementation — it is what SI is, and it is why PostgreSQL’s REPEATABLE READ (which is SI) will not save the on-call roster. SERIALIZABLE will.

The transaction axis is a subject of its own, and it has its own post — Transactions, when everyone writes at once — which derives the anomalies from conflict cycles and walks through locking, MVCC and SSI. Everything you need for this post is here; that one is a neighbour, not a missing half.

That last anomaly, write skew, deserves its reputation. Each transaction reads a set of rows, checks an invariant, and writes a different row. Each is individually correct. Under snapshot isolation neither sees the other’s write, because first-committer-wins only catches transactions writing the same row. Together they break the invariant. The on-call roster, the meeting-room booking, the account that must not go negative across two balances — all the same shape.

Why not just always use serializable? Often you should; the default reason not to is worse than people think it is. The honest cost is not that serializable is uniformly slower — modern SSI (Cahill et al., 2008, shipped in PostgreSQL since 9.1) is competitive at low contention. The cost is that it fails by aborting. At high contention, throughput does not gracefully degrade; it collapses, as a rising share of transactions abort and retry, adding load. Every caller needs retry logic that is actually correct. That is the real bill, and it is paid in operational complexity rather than in milliseconds.

CAP, stated properly, and the part that matters more

The theorem (Gilbert and Lynch, 2002, formalising Brewer’s conjecture): in an asynchronous network in which messages may be lost, no implementation can be both available and atomic — where atomic means linearizable. That is it.

Three corrections, all load-bearing:

  • You do not pick two of three. Partitions are not a design choice; the network does what it does. The choice is what you do during one, so the theorem offers two options, not three.
  • The C is linearizability, not the C in ACID. They are unrelated properties that happen to share a letter.
  • Partitions are rare; the tax is daily. This is the important one.

Abadi’s PACELC (2012) is the formulation worth carrying: if Partitioned, choose Availability or Consistency; Else — which is almost always — choose Latency or Consistency. The second clause is the one you pay for on a normal Tuesday, and it is just the round-trip floor from the first section wearing a suit. A linearizable write must be acknowledged by a quorum; a quorum is somewhere else; somewhere else is at least one round trip away.

What does R + W > N actually buy?Every claim below is arithmetic you can redo by hand.

R + W = 4 > N = 3 · 2W = 4 > N = 3

reads see completed writesholdsR + W > N: every read set meets every finished write set.
writes order themselvesholdsW > N/2: no two writes complete on disjoint replicas.
linearizable registerholdsBoth hold — but only once reads write back what they return.

Tolerates 1 replica down with both reads and writes still served. Note what the sliders will not let you escape: pushing W up to order writes pushes availability down, one replica at a time. That trade is the whole of the CAP result, expressed as two inequalities.

How it is actually built, and where each one breaks

Start with the naive approach, because it is what everyone builds first and its failure is where the rest comes from.

MechanismWhat it buysWhat it costsWhere it breaks
Single leader, async replicationSimple, fast writes, ordered logStale reads on followersFailover. The new leader is missing acknowledged writes — they are silently lost. Two leaders if the old one is alive and unfenced.
Single leader, sync replicationNo lost acknowledged writesWrite latency is the slowest required followerA slow replica becomes a write outage. Availability is the min over replicas, not the max.
QuorumsNo single point of failure; tunableRead amplification; version metadataIn-flight writes (above). Concurrent writes need vector clocks or last-write-wins, and LWW loses data by design.
Consensus (Paxos, Raft)One agreed log despite failuresOne majority round trip per decisionThe leader is a throughput ceiling and a latency floor for everyone far from it. Membership changes are where implementations get subtle.
Two-phase commitAtomic commit across shardsTwo round trips, locks held throughoutCoordinator dies after prepare and participants block, holding locks, indefinitely. Fixed only by replicating the coordinator’s log with consensus — which is what Spanner does.
MVCC + snapshot isolationReaders never block writersVersion storage; garbage collectionWrite skew. Also a long-running reader pins old versions and the table bloats.
SSITrue serializability, no read locksTracking read/write dependenciesContention: aborts rise, retries add load, throughput collapses rather than degrading.
Deterministic execution (Calvin)Agree on order once, then no 2PC at allNeeds the full read/write set before executionTransactions whose access set depends on what they read — handled by a reconnaissance pass, which can be invalidated and retried.
CRDTsConvergence with zero coordinationMetadata growth; restricted operationsAny invariant spanning objects. “Balance ≥ 0” is not expressible, and no amount of cleverness makes it so.

Is there a way to know whether I need coordination at all, rather than guessing? Yes, and it is the most underused result in the area. Bailis et al. (2014) define invariant confluence: an invariant and a set of operations are I-confluent if merging any two states that each satisfy the invariant yields a state that also satisfies it. If your workload is I-confluent, coordination is provably unnecessary — you can run it coordination-free and stay correct. If it is not, no implementation trick avoids coordination. It converts “how strong should this be?” from taste into a property you can check. Unique-username assignment is not I-confluent. Appending to a set is.

Clocks, and why the one on the wall is not one

Ordering events across machines means agreeing on time, and physical clocks disagree. NTP leaves milliseconds of skew on a good day, clocks step backwards, and virtual machines pause. Last-write-wins over wall-clock timestamps therefore does not resolve conflicts — it silently discards whichever write ran on the machine whose clock was behind.

  • Lamport clocks (1978): a counter per node, advanced on send and receive. Guarantees that if A causally precedes B then C(A) < C(B). The converse does not hold, so you cannot tell concurrency from ordering.
  • Vector clocks: one counter per node. Now you can detect concurrency exactly — at the cost of metadata proportional to the number of writers, which is why Dynamo-style systems struggle with them at scale.
  • Hybrid logical clocks (Kulkarni et al., 2014): physical time that is never allowed to go backwards, nudged forward by causality. Close enough to wall clock to be human-readable, strong enough to respect causality. Used by CockroachDB and MongoDB.
  • TrueTime (Spanner, 2012): the interesting move is admitting the uncertainty rather than hiding it. The API returns an interval guaranteed to contain the true time, with bounded error ε, backed by GPS and atomic clocks. Spanner then simply waits out 2ε before acknowledging a commit, so that commit order provably matches real time. It buys strict serializability with a hardware budget and a few milliseconds of deliberate delay.

Waiting on purpose seems like an admission of defeat — why is that a good design? Because uncertainty does not disappear if you refuse to measure it. Every other system has the same clock error; it simply does not know the bound, so it cannot wait it out and must instead avoid depending on physical time. Spanner pays a known, bounded cost in exchange for a guarantee nobody else could offer at the time. The lesson generalises: a bound you can name is worth far more than an error you have merely stopped thinking about.

Choosing, without cargo-culting

Four questions, in order. They resolve most real cases without any appeal to taste.

QuestionIf yesExample
Does an invariant span more than one object?Transactions, and serializable specifically — SI will let write skew throughdouble-entry ledger, on-call roster, seat inventory
Are the operations commutative?A CRDT converges with no coordination at alllike counts, tag sets, presence, collaborative text
Can two observers compare notes out of band?You need real time: linearizable, or strict serializable“I’ve paid, refresh your page”; service A writes then enqueues for service B
Does staleness cost money or trust?Pay for strength in proportiona balance, yes; a follower count, no

And one rule that saves more incidents than any of them: if the answer is “weak is fine”, add the session guarantees anyway. Read-your-writes and monotonic reads are cheap — usually a sticky routing decision or a token carried by the client — and they remove the overwhelming majority of staleness that users actually notice, which is their own writes disappearing and pages that go backwards when refreshed.

Who gives what

The table in the one-pager below lists defaults and strongest available settings per system. Two things about it matter more than its contents.

First, defaults are weak almost everywhere, and the name on the tin is often not the property in the tin: REPEATABLE READ means snapshot isolation in PostgreSQL, SERIALIZABLE means snapshot isolation in Oracle, and ZooKeeper — famously — gives linearizable writes but only sequentially consistent reads unless you call sync() first. Reading your own database’s isolation documentation is not a beginner activity.

Second, and more important: the documented guarantee and the delivered guarantee are different claims. That gap is not hypothetical, and it is the entire reason Kyle Kingsbury’s Jepsen exists — a long series of reports in which real databases, under real partitions, violated the models they advertised. The methodology is the lesson: do not reason about the implementation, record its history and check it, which is exactly what the sixty lines above do in miniature.

Where this is, as of September 2026

Dated deliberately, because this area moves and a post that reads as timeless about it is lying.

  • Bounded-uncertainty clocks are becoming ordinary. TrueTime was exotic in 2012 because it needed GPS receivers and atomic clocks in every datacentre. Cloud providers now sell tightly-bounded time as a service — AWS Time Sync offers microsecond- level accuracy, and Aurora DSQL builds strongly-consistent multi-region commits on it without classical two-phase commit. The consequence is that Spanner’s design is drifting from “what Google can afford” toward “what you can rent”.
  • Leaderless transaction protocols are shipping. Cassandra’s Accord (CEP-15) targets general-purpose transactions without a designated leader and with one round trip in the common case, building on the EPaxos line of work. The prize is removing the leader as both a latency floor for distant clients and a throughput ceiling.
  • Verification has caught up with implementation. Elle (Kingsbury and Alvaro, 2020) infers transactional anomalies from observed histories rather than searching for serial orders, which made checking practical at sizes that defeated earlier tools. Model checking designs in TLA+ or P before building them is now normal at several large shops rather than a research curiosity.
  • Local-first is the interesting frontier. Mature CRDT libraries (Automerge, Yjs) made coordination-free collaborative editing routine. The open problem is unchanged and is the one from the mechanisms table: invariants spanning objects. What is contested is how much of an application can be expressed I-confluently, and reasonable people disagree.

◆ The first and second items describe products and protocols that were moving quickly at the time of writing; treat version-specific details as things to verify, not as settled fact. The papers cited throughout are stable; the shipping status of anything is not.

The whole thing, on one page

For the second reading, and for the afternoon six months from now when you need the table rather than the argument. It prints.

Consistency on one page

Every rule below follows from the first panel. Prints on two sides.

The invariant

With one copy there is exactly one order, and every read sees it. Add copies and that single order is gone; what remains is a set of orders the system might show you. A consistency model is a rule narrowing that set. Strong means few admissible orders and more coordination; weak means many and less. Nothing else is going on.

One object — what a reader may see

LinearizableOne order, and it respects real time. An operation appears to take effect at an instant between its call and return.
SequentialOne order, per-process order kept, real time ignored.
CausalOrders agree only on causally related operations. Concurrent ones may be seen in either order.
SessionRYW, monotonic reads, monotonic writes, writes-follow-reads. Promises to you, not to everyone.
EventualIf writes stop, replicas converge. No bound, no promise before then.

Many objects — how transactions interleave

Strict serializableSerializable + linearizable. The gold standard; Spanner calls it external consistency.
SerializableSome serial order exists. Says nothing about which, or about real time.
Snapshot isolationReads from a consistent snapshot; first-committer-wins. Permits write skew.
Repeatable readANSI: no non-repeatable reads; phantoms allowed. In PostgreSQL this name means SI.
Read committedNo dirty reads. Everything else is on the table.

Anomalies, and the lowest level that stops each

anomalystopped atwhat it looks like
dirty writeread uncommittedtwo uncommitted writes interleave
dirty readread committedyou act on a value that gets rolled back
non-repeatable readrepeatable readsame row, same transaction, two values
lost updaterepeatable read / SItwo read-modify-writes, one increment vanishes
phantomSI (snapshot) / serializablea range query grows between reads
write skewserializable onlyeach transaction is legal; together they break an invariant

Quorum arithmetic

  • R + W > N — every read set meets every completed write set.
  • W > N/2 — two writes cannot complete on disjoint sets.
  • Tolerates N − max(R,W) replicas down.
  • Neither gives linearizability. An in-flight write has completed nothing; a read can see it and the next read can miss it. ABD fixes it: a read writes back what it returns.

CAP, stated properly

Gilbert & Lynch (2002): in an asynchronous network that may drop messages, no implementation can be both available and atomic (linearizable).

  • It is about partitions only. You do not “pick two”.
  • The C is linearizability, not the C in ACID.
  • PACELC (Abadi 2012) is the useful form: if Partitioned, A or C; Else, Latency or Consistency. The second half is what you pay every day.

Mechanisms, and where each breaks

mechanismbreaks at
single leader, sync replicationfailover: split brain without fencing; write latency is the slowest follower
quorums (Dynamo-style)in-flight writes; no ordering of concurrent writes without versions
consensus (Paxos, Raft)one majority RTT per decision; leader is a throughput ceiling
2PCblocks on coordinator loss unless the coordinator log is itself replicated
MVCC + SIwrite skew; long readers hold garbage
SSIaborts under contention — throughput collapses, it does not degrade
deterministic (Calvin)needs the full read/write set up front
CRDTscannot express an invariant spanning objects

Clocks

  • Lamport — counter; orders causally related events, cannot detect concurrency.
  • Vector — one counter per node; detects concurrency, costs O(nodes).
  • HLC — physical time that never goes backwards; readable and causal.
  • TrueTime — an interval with bounded error ε. Spanner waits out 2ε before committing so that commit order matches real time.
  • Wall clock — not a clock. Skew and steps make last-write-wins lose real writes.

Who gives what (check your version)

systemdefaultstrongest available
PostgreSQLread committedserializable (SSI). REPEATABLE READ here is SI.
MySQL / InnoDBrepeatable readserializable
Oracle, SQL Serverread committedOracle’s SERIALIZABLE is SI; SQL Server has true serializable
Spannerexternal consistencystrict serializability (TrueTime + Paxos + 2PC)
CockroachDB, YugabyteDBserializableserializable; Raft per range
FoundationDBstrict serializablestrict serializable
DynamoDBeventually consistent readsstrongly consistent reads (per item); transactions
Cassandratunable (quorum)Paxos-based LWT for compare-and-set
MongoDBlocal read concernmajority + causal sessions; snapshot transactions
Redisasync replicationnot linearizable across failover by design
etcdlinearizable readslinearizable (Raft ReadIndex)
ZooKeeperlinearizable writes, sequential readslinearizable reads only after sync()
Kafkaper-partition total orderacks=all + min.insync.replicas

Defaults move between versions, and the documented guarantee is not always the delivered one — which is why Jepsen exists. Verify against your version and your configuration.

Choosing, in four questions

  • Does an invariant span more than one object? If yes, you need transactions, and probably serializable — write skew is waiting.
  • Is the operation commutative? Counters, sets, adds: a CRDT converges without coordination.
  • Can two parties compare notes out of band? If a user can phone another user, staleness becomes visible and you need real time — linearizability, not just serializability.
  • What does staleness cost? A like count: nothing. A seat, a balance, a unique name: the whole point.

Verifying it

  • Record a history — operations with call and return times — and check it. Do not reason about your implementation; judge its output.
  • Jepsen for fault injection, Elle for inferring transactional anomalies from observed histories.
  • Checking linearizability is NP-hard in general; it is tractable on short histories, which is why checkers work on small traces.
  • Model-check the design (TLA+, P) and test the implementation. They fail differently.
legal / strongwrite / coordinationanomaly / impossiblestale / weak

Check yourself

Check yourself14 cards. If a card will not come, the answer names where to look.

The test this post is written against is that you can now rederive it. These are the claims worth being able to reproduce — not definitions to recall, but the steps of the argument.

Quick reference

Every abbreviation this post used, expanded, plus the neighbouring terms you will meet in the papers below. Ordered for lookup, not for reading.

Glossary38 terms, A–Z
ABD
Attiya–Bar-Noy–Dolev: the algorithm making a quorum register linearizable by having reads write back what they return.
ACID
Atomicity, Consistency, Isolation, Durability. Its C is your invariants, not this post’s consistency.
anomaly
A history a given isolation level permits but a serial execution could not produce.
BASE
Basically Available, Soft state, Eventual consistency. The marketing counterpart to ACID.
CAP
Consistency, Availability, Partition tolerance. Under partition you get availability or linearizability, not both.
causal consistency
Everyone agrees on the order of causally related operations; concurrent ones may differ.
CRDT
Conflict-free Replicated Data Type: a structure whose merge is commutative, associative and idempotent, so replicas converge with no coordination.
Elle
A checker that infers transactional anomalies from observed histories.
eventual consistency
If writes stop, replicas converge. No bound on when; no promise before then.
external consistency
Spanner’s name for strict serializability.
HLC
Hybrid Logical Clock: physical time that never moves backwards and respects causality.
history
The record of operations with their call and return times. What a checker judges.
I-confluence
Invariant confluence: merging two valid states yields a valid state, so coordination is provably unnecessary.
isolation
How concurrent transactions are allowed to interfere. The I in ACID.
Jepsen
Kyle Kingsbury’s fault-injection test suite, and the reports it produced.
linearizability
One order, respecting real time; each operation takes effect at an instant inside its own interval.
LWT
Lightweight transaction: Cassandra’s Paxos-based compare-and-set.
LWW
Last-write-wins: resolve conflicts by timestamp. Discards writes whenever clocks disagree.
MVCC
Multi-Version Concurrency Control: keep old row versions so readers never block writers.
NP-hard
At least as hard as the hardest problems in NP; no known polynomial algorithm.
NTP
Network Time Protocol. Leaves milliseconds of skew at best.
PACELC
If Partitioned: Availability or Consistency. Else: Latency or Consistency.
phantom
A row appearing in a repeated range query that was not in the first.
quorum
A subset of replicas large enough that any two required subsets overlap.
Raft / Paxos
Consensus algorithms: agree on one log entry despite failures, at one majority round trip each.
RTT
Round-trip time.
RYW
Read-your-writes: you always see your own writes. The cheapest guarantee users notice.
serializability
The outcome equals some serial order of transactions. Says nothing about real time.
sequential consistency
One agreed order preserving each process’s own order, ignoring real time.
session guarantees
RYW, monotonic reads, monotonic writes, writes-follow-reads. Promises to one client.
SI
Snapshot Isolation: read from a consistent snapshot, first committer wins. Permits write skew.
split brain
Two nodes both believing they are leader, both accepting writes.
SSI
Serializable Snapshot Isolation: SI plus dependency tracking, aborting cycles. PostgreSQL SERIALIZABLE.
strict serializability
Serializable and linearizable at once. The strongest useful model.
TrueTime
Spanner’s clock API returning a bounded interval rather than an instant.
2PC
Two-phase commit: prepare, then commit. Blocks if the coordinator dies mid-flight.
vector clock
One counter per node; detects concurrency exactly, at metadata cost.
write skew
Two transactions read overlapping data, write disjoint data, and jointly break an invariant. SI permits it.

References

Primary sources first, each with what you will find there — including at least one that complicates the argument above.

References11 sources