Each node keeps a counter per participant. Comparing vectors reveals a causal edge or a true concurrent write, which decides whether to merge or to ask.

One timestamp is not enough

A single timestamp cannot order concurrent events, because a clock is not a cause, so two nodes that each wrote something at the same label may still disagree about which came first. Vector clocks solve that by giving every participant its own counter: a process bumps its own slot whenever it does something, and merges the sender’s vector into its own whenever it receives a message. The result is a vector that grows as events happen, and comparing two vectors tells you whether one event provably preceded the other, or whether neither did.

Compare vectors to see causality

To compare vectors, look element by element: if every value in one vector is less than or equal to the matching value in the other, the first event happened before the second; otherwise the two events are concurrent. Because a causal order accumulates everywhere the two paths crossed, a vector captures the transitive and direct edges that a single scalar time forfeits. The moment a message passes from A to B, B’s vector reflects both B’s own progress and everything A had seen, which is how the model traces causality instead of guessing it.

Concurrent means someone decides

Concurrency in the vector sense does not produce a winner; it only says two events are unrelated or tied, so someone else must decide the order when the application needs one. That decision is usually made by comparing a key or by ordering on the vector values themselves, and it must be deterministic so every replica agrees. The design is honest: vector clocks do not fabricate a total order among unrelated writes, they only expose that no such order exists; the final ordering decision belongs to the rule above them.

When it breaks

Vector clocks fail first by growing. One entry exists per participant, so a key written by hundreds of short-lived clients carries hundreds of counters on every read, and every write ships them back. Dynamo reported exactly this pressure: reconciliation metadata grows with the writer set, and Riak operators see the same symptom as oversized causal context that slows reads and multiplies siblings. Evicting the oldest entries feels like relief, but it deletes causality.

Once node C is pruned from [A:3, B:2, C:5], later comparisons read the missing slot as zero, so an event C had already observed looks brand new, and a write that should have stayed subsumed resurrects as a false sibling.

The second failure misreads concurrent vectors as ordered. Comparing only the sum, the maximum, or a wall-clock timestamp beside the vector elects a winner between [A:2, B:1] and [A:1, B:2], yet neither dominates the other element by element, so the honest answer is concurrent, not before or after. The discarded sibling is a lost update the clock had flagged correctly. The mirror mistake treats one equal slot as equal history: [A:2] seen twice is the same event replayed, but [A:2] against [A:2, B:1] is a strict before relation, and overwriting the longer vector with the shorter one rewinds history.

The guard is strict: return causal context on every write, merge with element-wise maximum, increment only your own slot, and prune only with a dominance-preserving scheme such as dotted version vectors. A write without its read vector forks a sibling every time.

Check it quickly

Save this as check_vector_clocks.py and run python3 check_vector_clocks.py. It stores vectors as plain dicts with missing entries read as zero, then asserts all three outcomes: happens-before with equality in both directions, plus a concurrent pair whose merge dominates both parents.

def compare(a, b):
    """Return 'equal', 'before', 'after', or 'concurrent'."""
    nodes = set(a) | set(b)
    le = all(a.get(n, 0) <= b.get(n, 0) for n in nodes)
    ge = all(a.get(n, 0) >= b.get(n, 0) for n in nodes)
    if le and ge:
        return "equal"
    if le:
        return "before"  # a happens-before b
    if ge:
        return "after"  # b happens-before a
    return "concurrent"


def merge(a, b):
    return {n: max(a.get(n, 0), b.get(n, 0)) for n in set(a) | set(b)}


def tick(clock, node):
    nxt = dict(clock)
    nxt[node] = nxt.get(node, 0) + 1
    return nxt


# 1. Happens-before: b extends a with one new event from B.
a = {"A": 1}
b = {"A": 1, "B": 1}
assert compare(a, b) == "before"
assert compare(b, a) == "after"

# 2. Equal: the same causal history seen twice.
assert compare({"A": 2}, {"A": 2}) == "equal"

# 3. Concurrent: each side saw something the other missed.
c = {"A": 2, "B": 1}
d = {"A": 1, "B": 2}
assert compare(c, d) == "concurrent"

# Merge of concurrent histories dominates both parents.
m = merge(c, d)
assert m == {"A": 2, "B": 2}
assert compare(c, m) == "before"
assert compare(d, m) == "before"

# A truncated vector lies: dropping B makes m look merely later.
truncated = {"A": 2}
assert compare(truncated, m) == "before"  # missing B reads as 0, hiding what was pruned
print("PASS: before/after/equal/concurrent and merge all behave as expected")

Expect before and after on the extended vector, equal on identical histories, and concurrent on the split pair. The merge reads [A:2, B:2] and follows each parent, while truncated [A:2] still reports before, showing how pruning hides causality. The assertions passed with CPython 3.12 on macOS; the sketch omits pruning, dotted versions, and persistence, so treat it as a comparison demo, not a store.

Further reading

Sources