Three replicas each bump a shared counter while partitioned — A +100, B +50, C +30, so the answer must be 180. Merged as a single last-write-wins register the counter reports 100 and loses 80 increments; merged as a G-Counter CRDT it converges to exactly 180 in every one of the six merge orders.
Three replicas each bump a shared counter while partitioned — A does +100, B does +50, C does +30 — so the answer must be 180. Then they reconcile. Model the counter naively, as a single integer register merged by last-write-wins, and the merge keeps only one replica's work: the survivor is 100 and the other 80 increments vanish. Model it as a G-Counter CRDT — each replica keeps a vector of per-replica sub-counts, increment bumps its own entry, merge takes the element-wise MAX, value is the sum — and the replicas converge to exactly 180, in every merge order. Same increments, same true total; the only difference is what "merge" is allowed to know.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 6 — Replication, §"Conflict-free replicated datatypes and operational transformation" (and §"Automatic conflict resolution"):
CRDTs are data structures that can be concurrently edited [...] and which automatically resolve conflicts in sensible ways.
The book presents CRDTs as replicated types whose merge is associative, commutative, and idempotent, so replicas converge no matter the order updates arrive. Here you watch a counter do exactly that — and watch the naive version fail to.
The surprise is about what a merge is allowed to remember. These help; each line notes where it shows up.
merge() method both counters implement.{A:100, B:50, C:30} dict whose sum is the value.Two counters, same three replicas, same increments — only the merge differs.
{replica_id: count}. Increment bumps its own entry. Merge takes, for every replica id, the MAX of the two sub-counts; the value is the sum of the merged dict. Because A's, B's, and C's contributions live in separate slots, no merge can overwrite one with another.Both counters implement the same two-method interface (increment, merge); the exercise runs both against the identical workload. The whole thing is one script, code/g_counter_crdt.py.
cd study/ddia/ch06/code
python3 g_counter_crdt.py
Do not read the output yet — make each prediction first.
Predict. Three replicas increment while offline: A +100, B +50, C +30. Before any merge logic, what is the true total the counter should report after reconciliation?
180 — no surprise yet. The surprise is whether the merge can recover it.
Predict. Each replica holds a plain integer (A=100, B=50, C=30) and we merge them last-write-wins. What single number comes out — and how many of the 180 increments survive?
100, not 180 — 80 increments gone. LWW on a single register can only pick a value, never combine two. It keeps A's 100 (the largest) and silently discards B's 50 and C's 30. Nothing errored; the counter just quietly lost 44% of the writes.
Predict. Same three replicas, but now each is a dict — A={A:100}, B={B:50}, C={C:30} — merged by element-wise MAX and summed. What does it report?
Exactly 180, nothing lost. The MAX is taken per replica id, so merging {A:100} with {B:50} gives {A:100, B:50} — the entries don't collide, they coexist. Summing the merged dict recovers every increment.
Predict. There are six orders to merge three replicas. How many distinct final values will the G-Counter produce across all six?
One value — 180 — across all six orders. Element-wise MAX is commutative and associative, so the order the replicas reconcile in cannot change where they land. That is convergence: the replicas agree no matter how the network delivers their states.
A counter under concurrent updates is the cleanest case where "just pick a value" fails. When three replicas each accept increments during a partition, their states genuinely diverge, and reconciliation has to combine the divergent work, not choose among it. Last-write-wins can only choose. Its merge — keep the larger register — treats the two 3-digit numbers as rivals, so the moment it keeps A's 100 it has thrown B's 50 and C's 30 away. The bug isn't in the arithmetic; it's that a single integer has nowhere to record that its 100 and B's 50 came from different, non-overlapping streams of increments. With only one slot, one write must overwrite the other.
The G-Counter fixes this by giving every replica its own slot. Because A only ever bumps counts["A"] and B only ever bumps counts["B"], the sub-counts are independent — no increment made by one replica can ever be the "same" write as an increment made by another. Merge then doesn't have to choose: for each slot it takes the MAX, which for these disjoint streams just means "carry across whichever replica's entry we have," and the sum reassembles the whole. And because MAX is commutative (max(a,b) = max(b,a)), associative, and idempotent (max(a,a) = a), the merge forms a lattice — every replica climbs monotonically toward the same least-upper-bound state. That is why all six orders converge to 180: the destination is a property of the set of updates, not the order they arrive.
Sources: DDIA 2e, Ch. 6, §"Conflict-free replicated datatypes and operational transformation", §"Automatic conflict resolution", §"Last write wins (discarding concurrent writes)" · Shapiro et al., "Conflict-free Replicated Data Types" (2011).