How to Build a CRDT Counter in Python So Distributed Nodes Never Lose an Increment
Build a grow-only and positive-negative CRDT counter in Python, then prove its merge logic mathematically guarantees replicas never lose a concurrent increment, no matter what order they sync in.
If you run the same service on two servers in two regions, and both servers can accept writes even when they can’t talk to each other, you eventually have to merge their state back together. Most naive approaches to that merge quietly throw data away. In this tutorial you will build a small class of data structure, called a CRDT (Conflict-Free Replicated Data Type), that merges two independently updated counters back into one correct value automatically, no matter which order the merges happen in and no matter how many times a sync message gets replayed.
Table Of Content
- What a CRDT actually is, in plain terms
- Prerequisites
- Step 1: Reproduce why the obvious approach fails
- Step 2: Build a G-Counter (Grow-Only Counter)
- Why does that guarantee actually hold? Prove it, don’t assume it
- Step 3: Handle decrements with a PN-Counter
- Step 4: The gotcha, why you can’t just “reset” a slot
- Step 5: A realistic 3-node simulation with out-of-order gossip
- Step 6: Verify everything with pytest
- Common mistakes to watch for
- How to confirm it all works end to end
- Next steps
By the end you will have a working GCounter (grow-only counter) and PNCounter (increment-and-decrement counter) in plain Python, a reproduced bug that shows exactly why the obvious “just copy the newer value” approach fails, a proof (not just an assertion) that the merge logic is mathematically safe under any ordering, and a 3-node simulation that survives a network partition and out-of-order gossip without losing a single count.
What a CRDT actually is, in plain terms
A CRDT is a data structure with one very specific promise: any two replicas of it, no matter how their local histories diverged, can always be merged back into a single consistent state, with no conflicts and no coordination between the replicas required at write time. This is different from asking a database to lock a row, or from routing every write through one leader node. With a CRDT, every replica accepts writes independently and locally, and a merge() function reconciles them later.
For that to work safely, the merge function has to satisfy three properties, all of which you will prove empirically later in this tutorial rather than just take on faith:
- Commutative:
merge(A, B)produces the same result asmerge(B, A). The order two replicas sync in should never matter. - Associative:
merge(merge(A, B), C)produces the same result asmerge(A, merge(B, C)). It should not matter which pair merges first when three or more replicas are involved. - Idempotent:
merge(A, A)produces exactlyA. If a flaky network delivers the same sync message twice, applying it twice must not corrupt anything.
The concept was formally defined in 2011 by Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski (see the Wikipedia summary of the formal definition and known CRDT types), and today it underpins real production systems: Redis Software’s Active-Active (geo-distributed) databases are built on CRDT technology specifically so that applications in different regions can read and write the same data set with sub-millisecond local latency and automatic conflict resolution, and Riak and Cosmos DB expose CRDT data types as well. The two simplest and most teachable CRDTs, and the ones you’ll build here, are the G-Counter and the PN-Counter.
Prerequisites
- Python 3.10 or later (this tutorial was built and tested on 3.13.14). No third-party packages beyond
pytestare required; everything else is the standard library. pytestinstalled (pip install pytest) to run the verification suite in the last step.- Comfort with basic Python: classes, dictionaries, and simple functions. No prior distributed-systems background is assumed; every term is defined before it’s used.
Step 1: Reproduce why the obvious approach fails
Before building anything clever, it’s worth seeing the naive approach actually break, on purpose, so the rest of this tutorial has a concrete problem to solve. Imagine a “like counter” on a post, served by two application servers, node_a and node_b, that both start in sync.
class NaiveCounter:
def __init__(self):
self.value = 0
def increment(self, amount=1):
self.value += amount
def sync_last_write_wins(local, remote):
"""A naive 'sync': whichever replica's value we copy last wins."""
local.value = remote.value
return local
if __name__ == "__main__":
node_a = NaiveCounter()
node_b = NaiveCounter()
node_a.value = 10
node_b.value = 10
print(f"Starting state: node_a={node_a.value}, node_b={node_b.value}")
# A network partition happens. Both nodes keep accepting likes locally
# while they can't reach each other.
for _ in range(3):
node_a.increment() # 3 new likes land on node_a
for _ in range(5):
node_b.increment() # 5 new likes land on node_b
print(f"After partition: node_a={node_a.value}, node_b={node_b.value}")
print(f"Real total likes that happened: {3 + 5} (plus the original 10) = 18")
# The partition heals. We "sync" by copying whichever value we see last.
sync_last_write_wins(node_a, node_b)
print(f"After naive sync (copy node_b into node_a): node_a={node_a.value}")
print("The 3 likes that landed on node_a were silently discarded.")
Run it (python step1_naive_bug.py) and this is the real captured output:
Starting state: node_a=10, node_b=10
After partition: node_a=13, node_b=15
Real total likes that happened: 8 (plus the original 10) = 18
After naive sync (copy node_b into node_a): node_a=15
The 3 likes that landed on node_a were silently discarded.
Eighteen real likes happened. The naive sync reports fifteen. This isn’t a contrived edge case, it’s what happens by default any time two writers can both touch the same value and you resolve conflicts by “whoever synced last wins.” The fix isn’t a smarter tiebreaker; it’s a data structure whose merge function can never lose information in the first place.
Step 2: Build a G-Counter (Grow-Only Counter)
The trick behind a G-Counter is simple once you see it: instead of one shared integer, every node gets its own private slot in a dictionary, and a node is only ever allowed to increment its own slot. The counter’s total value is the sum of every slot. Merging two G-Counters means taking the element-wise maximum of every slot, never a wholesale copy.
class GCounter:
"""Grow-only counter: each node tracks its own slot, merge takes the max."""
def __init__(self, node_id):
self.node_id = node_id
self.counts = {node_id: 0}
def increment(self, amount=1):
if amount < 0:
raise ValueError("GCounter cannot decrement; use PNCounter instead")
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + amount
def value(self):
return sum(self.counts.values())
def merge(self, other):
"""Merge another GCounter's state into this one, in place."""
for node, count in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), count)
def copy(self):
clone = GCounter(self.node_id)
clone.counts = dict(self.counts)
return clone
def __repr__(self):
return f"GCounter({self.node_id}, counts={self.counts}, value={self.value()})"
This is a direct implementation of the state-based G-Counter algorithm as formally specified: each node maintains a payload array indexed by node ID, increment() only ever writes to its own index, value() sums the whole array, and merge(X, Y) sets every index of the result to max(X[i], Y[i]). That last rule is the entire trick. A node’s own count can only ever go up, so max() can never accidentally erase a real increment, because the true value at any slot is always the largest number anyone has ever seen written there.
Now replay the exact same like-counter scenario from Step 1, but with a GCounter instead of a plain integer:
if __name__ == "__main__":
node_a = GCounter("node_a")
node_b = GCounter("node_b")
node_a.counts = {"node_a": 10, "node_b": 10}
node_b.counts = {"node_a": 10, "node_b": 10}
print(f"Starting: {node_a}")
print(f"Starting: {node_b}")
for _ in range(3):
node_a.increment()
for _ in range(5):
node_b.increment()
print(f"\nAfter partition: {node_a}")
print(f"After partition: {node_b}")
node_a.merge(node_b)
node_b.merge(node_a)
print(f"\nAfter merge: {node_a}")
print(f"After merge: {node_b}")
print(f"\nBoth nodes agree: {node_a.value() == node_b.value()}")
print(f"Expected total (20 original + 3 + 5): {20 + 3 + 5}")
Real captured output:
Starting: GCounter(node_a, counts={'node_a': 10, 'node_b': 10}, value=20)
Starting: GCounter(node_b, counts={'node_a': 10, 'node_b': 10}, value=20)
After partition: GCounter(node_a, counts={'node_a': 13, 'node_b': 10}, value=23)
After partition: GCounter(node_b, counts={'node_a': 10, 'node_b': 15}, value=25)
After merge: GCounter(node_a, counts={'node_a': 13, 'node_b': 15}, value=28)
After merge: GCounter(node_b, counts={'node_a': 13, 'node_b': 15}, value=28)
Both nodes agree: True
Expected total (20 original + 3 + 5): 28
All 28 likes survive, and both nodes converge to the identical answer, without either node ever being told which one “won.” Merging {'node_a': 13, 'node_b': 10} with {'node_a': 10, 'node_b': 15} just takes max(13,10)=13 for node_a’s slot and max(10,15)=15 for node_b’s slot: nothing about that step depends on which merge call happened first.
Why does that guarantee actually hold? Prove it, don’t assume it
Rather than trust that description, verify the three merge properties from earlier directly against the code:
from gcounter import GCounter
def make_three_nodes():
a = GCounter("node_a")
b = GCounter("node_b")
c = GCounter("node_c")
a.counts = {"node_a": 5, "node_b": 2, "node_c": 0}
b.counts = {"node_a": 3, "node_b": 8, "node_c": 1}
c.counts = {"node_a": 5, "node_b": 2, "node_c": 4}
return a, b, c
def merged_counts(x, y):
result = x.copy()
result.merge(y)
return result.counts
if __name__ == "__main__":
a, b, _ = make_three_nodes()
# Commutative: merge(a, b) == merge(b, a)
ab = merged_counts(a, b)
ba = merged_counts(b, a)
print(f"merge(a, b) = {ab}")
print(f"merge(b, a) = {ba}")
print(f"Commutative: {ab == ba}\n")
# Associative: merge(merge(a, b), c) == merge(a, merge(b, c))
a, b, c = make_three_nodes()
left = a.copy()
left.merge(b)
left.merge(c)
a, b, c = make_three_nodes()
right = b.copy()
right.merge(c)
right.merge(a)
print(f"merge(merge(a, b), c) = {left.counts}")
print(f"merge(a, merge(b, c)) = {right.counts}")
print(f"Associative: {left.counts == right.counts}\n")
# Idempotent: merge(a, a) == a
a, _, _ = make_three_nodes()
before = dict(a.counts)
a.merge(a.copy())
print(f"a before self-merge: {before}")
print(f"a after self-merge: {a.counts}")
print(f"Idempotent: {before == a.counts}")
Real captured output:
merge(a, b) = {'node_a': 5, 'node_b': 8, 'node_c': 1}
merge(b, a) = {'node_a': 5, 'node_b': 8, 'node_c': 1}
Commutative: True
merge(merge(a, b), c) = {'node_a': 5, 'node_b': 8, 'node_c': 4}
merge(a, merge(b, c)) = {'node_a': 5, 'node_b': 8, 'node_c': 4}
Associative: True
a before self-merge: {'node_a': 5, 'node_b': 2, 'node_c': 0}
a after self-merge: {'node_a': 5, 'node_b': 2, 'node_c': 0}
Idempotent: True
The idempotence result is the one that’s easy to underrate. In a real system, sync messages get retried after timeouts, load balancers occasionally deliver a duplicate, and gossip protocols intentionally re-send state to nodes that may have already seen it. A merge function that isn’t idempotent would double-count every one of those retries. Because max() is idempotent by definition, merging a G-Counter with itself, or with any state it has already absorbed, changes nothing.
Step 3: Handle decrements with a PN-Counter
A G-Counter has an obvious limitation: it can only grow. That’s fine for a “total requests served” counter, but not for something like “current active viewers,” which needs to go down as well as up. The fix is not to make a single slot decrementable (that would break the max-based merge). Instead, a PN-Counter (Positive-Negative Counter) keeps two separate G-Counters under the hood, one that only ever receives increments (P) and one that only ever receives decrements (N), and reports value() = P.total() - N.total().
from gcounter import GCounter
class PNCounter:
"""Two G-Counters underneath: one for increments, one for decrements."""
def __init__(self, node_id):
self.node_id = node_id
self.positive = GCounter(node_id)
self.negative = GCounter(node_id)
def increment(self, amount=1):
self.positive.increment(amount)
def decrement(self, amount=1):
self.negative.increment(amount) # decrementing = incrementing the "negative" side
def value(self):
return self.positive.value() - self.negative.value()
def merge(self, other):
self.positive.merge(other.positive)
self.negative.merge(other.negative)
def copy(self):
clone = PNCounter(self.node_id)
clone.positive = self.positive.copy()
clone.negative = self.negative.copy()
return clone
def __repr__(self):
return f"PNCounter({self.node_id}, value={self.value()})"
Notice that decrement() doesn’t subtract from anything. It calls self.negative.increment(amount), which means the negative side is itself a perfectly ordinary G-Counter, grow-only, max-based merge, all the same guarantees already proven above. A decrement is just an increment recorded on the other side of the ledger.
Test it with a viewer-count scenario where viewers both join and leave on two nodes during a partition:
if __name__ == "__main__":
node_a = PNCounter("node_a")
node_b = PNCounter("node_b")
for _ in range(10):
node_a.increment() # 10 viewers joined via node_a
for _ in range(15):
node_b.increment() # 15 viewers joined via node_b
for _ in range(4):
node_a.decrement() # 4 viewers left via node_a
for _ in range(3):
node_b.decrement() # 3 viewers left via node_b
print(f"Before merge: node_a value={node_a.value()}, node_b value={node_b.value()}")
node_a.merge(node_b)
node_b.merge(node_a)
print(f"After merge: node_a value={node_a.value()}, node_b value={node_b.value()}")
print(f"Expected: (10 + 15) joined - (4 + 3) left = {10 + 15 - 4 - 3}")
Real captured output:
Before merge: node_a value=6, node_b value=12
After merge: node_a value=18, node_b value=18
Expected: (10 + 15) joined - (4 + 3) left = 18
Both nodes converge on 18 active viewers, the mathematically correct answer, even though each node only ever saw half of the joins and half of the leaves directly.
Step 4: The gotcha, why you can’t just “reset” a slot
Here’s a mistake that looks completely reasonable and will quietly undo itself. Say an operator wants to reset a counter back to zero on one node, maybe to clear out test data. The tempting shortcut is to reach in and zero out that node’s own slot directly:
def reset_node_slot_naively(counter, node_id):
"""A tempting but WRONG way to 'reset' a counter: zero out one slot."""
counter.counts[node_id] = 0
if __name__ == "__main__":
node_a = GCounter("node_a")
node_b = GCounter("node_b")
node_a.increment(50)
node_b.merge(node_a) # node_b now knows node_a is at 50
print(f"node_a: {node_a}")
print(f"node_b: {node_b}")
reset_node_slot_naively(node_a, "node_a")
print(f"\nAfter 'resetting' node_a's slot to 0: {node_a}")
node_a.increment(3)
print(f"node_a after 3 more increments: {node_a}")
node_b.merge(node_a)
print(f"\nnode_b after merging the 'reset' node_a: {node_b}")
Real captured output:
node_a: GCounter(node_a, counts={'node_a': 50}, value=50)
node_b: GCounter(node_b, counts={'node_b': 0, 'node_a': 50}, value=50)
After 'resetting' node_a's slot to 0: GCounter(node_a, counts={'node_a': 0}, value=0)
node_a after 3 more increments: GCounter(node_a, counts={'node_a': 3}, value=3)
node_b after merging the 'reset' node_a: GCounter(node_b, counts={'node_b': 0, 'node_a': 50}, value=50)
The reset gets silently undone the moment node_b merges again. max(50, 3) is 50, so node_b’s own record of node_a’s slot simply overrides node_a’s freshly-zeroed value the next time they sync. This isn’t a bug in the merge logic; it’s the merge logic doing exactly what it’s supposed to do. A G-Counter’s whole safety guarantee rests on the invariant that a node’s slot can only ever increase, so any code path that decreases a slot directly (rather than through the negative side of a PN-Counter) breaks the one promise the data structure exists to make. If you actually need a resettable counter, you generally have to mint a brand-new counter identity and retire the old one, rather than mutate a slot in place.
Step 5: A realistic 3-node simulation with out-of-order gossip
Two nodes syncing directly is the easy case. Real systems usually propagate state through gossip: node A syncs with B, B syncs with C, C syncs back with A, and the order those pairwise syncs happen in is not something any single node controls. This step simulates three edge servers recording page views for a video during a partition, then reconciling through gossip in a randomized order, to check that the final answer really doesn’t depend on that order.
import random
from gcounter import GCounter
def simulate(seed):
rng = random.Random(seed)
edge_us = GCounter("edge_us")
edge_eu = GCounter("edge_eu")
edge_asia = GCounter("edge_asia")
nodes = {"edge_us": edge_us, "edge_eu": edge_eu, "edge_asia": edge_asia}
total_real_views = 0
for name, node in nodes.items():
views = rng.randint(20, 80)
node.increment(views)
total_real_views += views
print(f" {name} recorded {views} views while partitioned")
print(f" Real total views across all 3 servers: {total_real_views}")
sync_pairs = [("edge_us", "edge_eu"), ("edge_eu", "edge_asia"), ("edge_asia", "edge_us")]
rng.shuffle(sync_pairs)
print(f"\n Gossip sync order this run: {sync_pairs}")
for a, b in sync_pairs:
nodes[a].merge(nodes[b])
nodes[b].merge(nodes[a])
# A second gossip round in case the first left anyone behind.
for a, b in sync_pairs:
nodes[a].merge(nodes[b])
nodes[b].merge(nodes[a])
print(f"\n What each server reports after gossip converges:")
all_agree = True
for name, node in nodes.items():
print(f" {name}.value() = {node.value()}")
if node.value() != total_real_views:
all_agree = False
print(f"\n All 3 servers agree with the real total: {all_agree}")
return all_agree, total_real_views
if __name__ == "__main__":
for seed in (1, 2, 3, 4, 5):
print(f"\n=== Run with random seed {seed} ===")
ok, total = simulate(seed)
assert ok, f"Servers disagreed after gossip converged (seed={seed})"
print("\nAll 5 runs converged to the correct total regardless of sync order.")
Real captured output for the first run (seeds 2 through 5 all converge the same way, just with different random view counts and a different shuffled sync order):
=== Run with random seed 1 ===
edge_us recorded 28 views while partitioned
edge_eu recorded 56 views while partitioned
edge_asia recorded 74 views while partitioned
Real total views across all 3 servers: 158
Gossip sync order this run: [('edge_asia', 'edge_us'), ('edge_eu', 'edge_asia'), ('edge_us', 'edge_eu')]
What each server reports after gossip converges:
edge_us.value() = 158
edge_eu.value() = 158
edge_asia.value() = 158
All 3 servers agree with the real total: True
All five randomized runs converged on the correct total, each with a different random view count and a different shuffled gossip order. That’s the point of proving commutativity and associativity earlier: it’s what guarantees this simulation didn’t need to get the sync order right to get the right answer. Any pairing, in any order, arrives at the same place, which is exactly what makes CRDTs practical for gossip-based systems where you generally can’t control or predict propagation order.
Step 6: Verify everything with pytest
Manually reading printed output is good for building intuition, but the properties this tutorial claims (commutative, associative, idempotent, correct under decrements, correct under randomized gossip) should be checked automatically. Save this as test_crdt_counter.py alongside gcounter.py, pncounter.py, and three_node_sim.py:
import pytest
from gcounter import GCounter
from pncounter import PNCounter
def test_gcounter_increment_and_value():
counter = GCounter("node_a")
counter.increment(5)
counter.increment(2)
assert counter.value() == 7
def test_gcounter_rejects_negative_increment():
counter = GCounter("node_a")
with pytest.raises(ValueError):
counter.increment(-1)
def test_gcounter_merge_is_commutative():
a = GCounter("node_a")
b = GCounter("node_b")
a.counts = {"node_a": 5, "node_b": 2}
b.counts = {"node_a": 3, "node_b": 8}
ab = a.copy()
ab.merge(b)
ba = b.copy()
ba.merge(a)
assert ab.counts == ba.counts
def test_gcounter_merge_is_associative():
a = GCounter("node_a")
b = GCounter("node_b")
c = GCounter("node_c")
a.counts = {"node_a": 5, "node_b": 2, "node_c": 0}
b.counts = {"node_a": 3, "node_b": 8, "node_c": 1}
c.counts = {"node_a": 5, "node_b": 2, "node_c": 4}
left = a.copy()
left.merge(b)
left.merge(c)
right = b.copy()
right.merge(c)
right.merge(a)
assert left.counts == right.counts
def test_gcounter_merge_is_idempotent():
a = GCounter("node_a")
a.counts = {"node_a": 5, "node_b": 2}
before = dict(a.counts)
a.merge(a.copy())
assert a.counts == before
def test_gcounter_merge_never_loses_data_regardless_of_order():
node_a = GCounter("node_a")
node_b = GCounter("node_b")
node_a.increment(3)
node_b.increment(5)
node_a.merge(node_b)
node_b.merge(node_a)
assert node_a.value() == node_b.value() == 8
def test_pncounter_increment_and_decrement():
counter = PNCounter("node_a")
counter.increment(10)
counter.decrement(4)
assert counter.value() == 6
def test_pncounter_merge_reconciles_concurrent_join_and_leave():
node_a = PNCounter("node_a")
node_b = PNCounter("node_b")
node_a.increment(10)
node_b.increment(15)
node_a.decrement(4)
node_b.decrement(3)
node_a.merge(node_b)
node_b.merge(node_a)
assert node_a.value() == node_b.value() == 18
def test_three_node_gossip_converges_regardless_of_sync_order():
from three_node_sim import simulate
for seed in range(10):
converged, _ = simulate(seed)
assert converged
Run it with pytest test_crdt_counter.py -v. Real captured output:
============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0
collected 9 items
test_crdt_counter.py::test_gcounter_increment_and_value PASSED [ 11%]
test_crdt_counter.py::test_gcounter_rejects_negative_increment PASSED [ 22%]
test_crdt_counter.py::test_gcounter_merge_is_commutative PASSED [ 33%]
test_crdt_counter.py::test_gcounter_merge_is_associative PASSED [ 44%]
test_crdt_counter.py::test_gcounter_merge_is_idempotent PASSED [ 55%]
test_crdt_counter.py::test_gcounter_merge_never_loses_data_regardless_of_order PASSED [ 66%]
test_crdt_counter.py::test_pncounter_increment_and_decrement PASSED [ 77%]
test_crdt_counter.py::test_pncounter_merge_reconciles_concurrent_join_and_leave PASSED [ 88%]
test_crdt_counter.py::test_three_node_gossip_converges_regardless_of_sync_order PASSED [100%]
============================== 9 passed in 0.05s ==============================
The last test is worth calling out specifically: test_three_node_gossip_converges_regardless_of_sync_order reruns the randomized 3-node simulation across 10 different seeds, meaning 10 different random view counts and 10 different shuffled gossip orders, and asserts every single one converges. That single test is doing the real work of confirming the ordering-independence guarantee this whole tutorial is built around, not just the two library-level demos shown earlier.
Common mistakes to watch for
- Mutating a slot directly instead of going through increment/decrement. Step 4 showed exactly why: it breaks the monotonic-growth invariant the whole merge algorithm depends on, and the mistake will silently un-fix itself on the next merge rather than raising an error.
- Reusing a node’s identity across two different physical machines. If two servers both increment using the node ID
"node_a", their writes collide in the same dictionary slot and the counter undercounts, exactly like the naive last-write-wins bug from Step 1, just one level down. Every real replica needs its own unique, stable node ID. - Forgetting that a G-Counter cannot decrement at all. The
increment(-1)call in this tutorial’s code deliberately raisesValueErrorrather than silently corrupting the slot, specifically to catch this mistake at the point it’s made instead of during some later merge. - Assuming “eventually consistent” means “instantly consistent.” Right after a partition heals but before every pair of nodes has actually gossiped with each other, nodes can briefly report different values. That’s expected. The simulation in Step 5 runs two full gossip rounds specifically to make sure every pair has actually exchanged state before checking for agreement; a single round isn’t guaranteed to reach every node.
How to confirm it all works end to end
- Place
gcounter.py,pncounter.py, andthree_node_sim.pyin one directory. - Run
python gcounter.pyand confirm both nodes report the identical merged value. - Run
python pncounter.pyand confirm the merged value matches the hand-computed expected total. - Run
python three_node_sim.pyand confirm all five randomized runs print “All 5 runs converged to the correct total regardless of sync order.” - Run
pytest test_crdt_counter.py -vand confirm all 9 tests pass.
If every one of those checks passes, you have a counter whose correctness under concurrent, out-of-order updates isn’t a hope, it’s a property you’ve proven with code.
Next steps
A CRDT counter resolves conflicts automatically, but it can only do that because the domain (a running total) is simple enough that “the largest value anyone has recorded” is always the right merge rule. Two natural directions to go from here:
- If you need to detect that two writes happened concurrently, rather than automatically resolve them, read How to Build Vector Clocks in Python to Detect Concurrent Writes in a Distributed System. Vector clocks and CRDTs solve related but different problems: vector clocks tell you a conflict happened and let you decide what to do about it; a CRDT’s merge function decides for you, automatically, as part of the data structure’s design.
- If your operation genuinely can’t be resolved by a commutative merge rule (for example, transferring money between two accounts, where you can’t just take the max of two balances), you need actual coordination between replicas instead. How to Implement Two-Phase Commit in Python So Two Databases Commit or Abort Together covers the coordinated alternative, and How to Build Leader Election in Python to Stop Split-Brain Writes covers how a cluster agrees on which single node is allowed to coordinate in the first place.








No Comment! Be the first one.