Choosing Between LWW, Field-Union, and CRDT Merge
You have conflicting leaves piling up in CouchDB and a resolver to write, but the real decision is upstream of any code: which merge strategy actually fits the shape of your documents? Pick wrong and you either silently discard edits users expected to keep, or you bolt CRDT machinery onto a document that never needed it. This is a decision guide — it maps concrete data patterns to one of three strategies so you commit to the right one before writing a resolver. It sits directly under algorithm selection for merge and feeds the three implementation guides that follow. The rule that drives everything below: match the strategy to how your writers actually mutate the document, not to how sophisticated you wish the system were.
Prerequisites: Read Your Document Shape First
Before you choose, gather evidence about how the document actually changes. The strategy follows from the write pattern, and that pattern is observable.
- Sample the conflicts you already have. Pull a handful of live conflicts and diff the losing leaves against the winner:
curl 'http://localhost:5984/appdb/{id}?conflicts=true', thenGET ?rev=each entry. If the branches differ on one field, you have a disjoint-edit pattern; if they both bump the same numeric field, you have accumulation. - Classify the writer intent. Ask who writes the field and why. A single owning device reporting the latest sensor reading is convergent overwrite. A config document where the firmware team edits one key and the network team edits another is disjoint. A like-count or an offline shopping cart is accumulation.
- Check for an application timestamp. LWW is only safe when every writer stamps a trustworthy
updated_at;_revordering is topological, never chronological, as revision tree mechanics explains. No reliable clock rules LWW out immediately. - Environment. Python 3.10+ and
requests. The dispatcher below is pure routing logic and needs no live database to run, so you can validate the decision offline first.
The Decision Matrix
Map your observed pattern to a strategy using the table. Each row is a data shape you can recognise from the samples above; the columns are the trade-offs you are committing to.
| Data pattern | Strategy | Consistency | Write latency | Complexity | Failure mode |
|---|---|---|---|---|---|
| High-churn telemetry, single-owner sensor state, cache-like values | Last-Write-Wins | Convergent, lossy | Lowest (one write) | Low | Clock skew promotes a stale write; the loser is gone |
| Config/profile docs whose branches edit different keys | Field-Union | Convergent, lossless for disjoint fields | Low (read leaves + one batch) | Medium | Two branches edit the same field → still needs a tiebreak |
| Counters, sets, tag lists, offline-accumulated tallies | CRDT (state-based) | Strong eventual, lossless | Medium (per-replica structure) | High | Replica-id sprawl, schema lock-in, integer overflow |
| Free-form collaborative text or ordered lists | CRDT (sequence type) | Strong eventual, lossless | Highest (op metadata) | Very high | Metadata outgrows payload; usually offload to a dedicated library |
The matrix collapses to one question per row: is loss acceptable (LWW), is the conflict spatially disjoint across fields (Field-Union), or does the value accumulate regardless of order (CRDT)? Answer that and the resolver writes itself.
“If Your Docs Look Like X, Pick Y”
Concrete recognition rules beat abstract properties. Hold each pattern against your sampled documents.
- If your document is a latest-value report — a sensor emitting
{"reading": 21.4, "updated_at": ...}where only the newest value matters and older readings are noise — pick Last-Write-Wins. Follow implementing Last-Write-Wins in CouchDB. - If your document is a settings bag —
{"firmware_version": ..., "network_config": ..., "display_name": ...}where different actors own different keys and two branches almost never touch the same key — pick Field-Union. Follow implementing Field-Union merge in CouchDB. - If your document is a tally or a set — a like-counter, an inventory decrement, an offline cart where two devices each add items and both additions must survive — pick a CRDT. Follow implementing CRDT counters in CouchDB.
- If your document mixes shapes — a latest-value block and a counter in the same
_id— split the fields by strategy inside one resolver rather than forcing a single algorithm across the whole document. The dispatcher below is built for exactly that routing.
Complete Working Example
The dispatcher routes each conflicted document to the strategy its doc_type demands. It keeps strategy selection in one auditable place so a new document class is a table entry, not a scattered if branch. The three resolver bodies are stubs here that stand in for the full implementations linked above; the routing, precondition checks, and fallback are complete and runnable.
import os
from datetime import datetime, timezone
import requests
# doc_type -> strategy name. This registry IS the decision; keep it declarative.
STRATEGY_BY_TYPE = {
"sensor_reading": "lww",
"device_cache": "lww",
"device_config": "field_union",
"user_profile": "field_union",
"like_counter": "crdt",
"inventory_tally": "crdt",
}
def parse_ts(value: str) -> datetime:
"""Parse an app timestamp as timezone-aware UTC (LWW safety guard)."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
def fetch_branches(db_url: str, doc_id: str) -> tuple[dict, list[dict]]:
"""Return (winner_body, [winner, *losers]) for a conflicted document."""
winner = requests.get(f"{db_url}/{doc_id}", params={"conflicts": "true"}).json()
branches = [winner]
for rev in winner.get("_conflicts", []):
branches.append(requests.get(f"{db_url}/{doc_id}", params={"rev": rev}).json())
return winner, branches
def resolve_lww(winner, branches):
"""Newest updated_at wins; ties break on highest _rev. (Lossy.)"""
chosen = max(branches, key=lambda d: (parse_ts(d.get("updated_at", "1970-01-01Z")), d["_rev"]))
return {k: v for k, v in chosen.items() if not k.startswith("_")}
def resolve_field_union(winner, branches):
"""Keep each field from whichever branch stamped it most recently."""
merged, field_ts = {}, {}
for b in branches:
stamp = b.get("field_updated_at", {})
for key, value in b.items():
if key.startswith("_") or key == "field_updated_at":
continue
ts = stamp.get(key, b.get("updated_at", "1970-01-01Z"))
if key not in field_ts or ts > field_ts[key]:
merged[key], field_ts[key] = value, ts
return merged
def resolve_crdt(winner, branches):
"""Element-wise max of per-replica sub-counters across all leaves."""
p, n = {}, {}
for b in branches:
for node, v in b.get("p", {}).items():
p[node] = max(p.get(node, 0), v)
for node, v in b.get("n", {}).items():
n[node] = max(n.get(node, 0), v)
return {"p": p, "n": n, "value": sum(p.values()) - sum(n.values())}
RESOLVERS = {"lww": resolve_lww, "field_union": resolve_field_union, "crdt": resolve_crdt}
def select_strategy(doc_type: str) -> str:
"""Route a document class to its merge strategy, defaulting to review."""
return STRATEGY_BY_TYPE.get(doc_type, "manual_review")
def resolve(db_url: str, doc_id: str) -> dict:
winner, branches = fetch_branches(db_url, doc_id)
if not winner.get("_conflicts"):
return {"ok": True, "note": "no conflict"}
strategy = select_strategy(winner.get("doc_type", ""))
if strategy == "manual_review":
return {"ok": False, "route": "manual_review", "doc_type": winner.get("doc_type")}
merged = RESOLVERS[strategy](winner, branches)
return {"ok": True, "strategy": strategy, "merged_preview": merged}
if __name__ == "__main__":
url = os.environ.get("COUCH_URL", "http://localhost:5984/appdb")
doc = os.environ.get("DOC_ID", "device-42")
print(resolve(url, doc))
The dispatcher deliberately defaults unknown doc_type values to manual_review rather than guessing a strategy — an unrouted document is a decision you have not made yet, and forcing LWW onto it is how silent data loss enters production.
Gotchas & Edge Cases
- LWW’s convergence is not safety. Every strategy here converges; only LWW converges by throwing an edit away. “It stops conflicting” is not evidence it kept the right data. Reserve it for values you can afford to lose.
- Field-Union is only lossless while edits stay disjoint. The moment two branches write the same key, Field-Union needs the same timestamp or
_revtiebreak LWW uses — and it becomes lossy for that field. Audit your real conflict distribution before assuming disjointness. - CRDTs change your schema permanently. A counter stored as
{"p": {...}, "n": {...}}cannot be read by code expecting a plain integer. Adopting a CRDT is a data-model commitment across every reader, not a resolver you can swap out later. - Do not mix LWW and CRDT on the same document. An LWW overwrite of a CRDT field discards other replicas’ increments — the exact loss the CRDT existed to prevent. Partition fields by strategy or split them into separate documents.
- The choice can be per-field, not per-document. A single
_idmay hold an LWW status field and a CRDT counter. Route inside the resolver, as the dispatcher does, rather than picking one algorithm for the whole document.
Verification & Observability
Validate the decision, not just the code. First, replay your sampled real conflicts through the dispatcher offline and confirm each doc_type lands on the strategy you intended and that nothing falls through to manual_review unexpectedly. Second, once resolvers run live, watch the per-document ?conflicts=true array clear and track a per-strategy resolution counter — a spike in the LWW counter for a doc_type you meant to route to Field-Union means a registry gap. Third, alert on the manual_review rate: a rising tail of unrouted document types is your signal to extend STRATEGY_BY_TYPE before the backlog grows. When a document genuinely resists all three strategies, route it through fallback resolution chains rather than defaulting to a lossy write.
FAQ
Can I start with Last-Write-Wins and migrate to a CRDT later if I need it?
You can, but it is a schema migration, not a config change. LWW stores a plain value; a CRDT stores per-replica sub-structures. Migrating means rewriting every document into the CRDT shape and updating every reader that expected the scalar. It is far cheaper to classify the document correctly up front using the matrix than to reshape live data after loss has already occurred. Reserve LWW for values where that loss is genuinely acceptable.
What if different fields in one document need different strategies?
That is common and fully supported — route per field inside a single resolver. A device document might resolve its status field by Last-Write-Wins and its error_count field as a CRDT counter in the same pass. The dispatcher pattern above generalises to this: dispatch on the field’s declared strategy rather than the whole document’s doc_type. Never let one algorithm overwrite a field that another strategy owns.
Is Field-Union just Last-Write-Wins applied per field?
For disjoint edits, effectively yes — each field keeps the value from the branch that last touched it, so non-overlapping edits all survive where whole-document LWW would drop the losing branch entirely. The difference appears when two branches edit the same field: there Field-Union degrades to a per-field LWW tiebreak and becomes lossy for that field only. Field-Union is the lossless choice precisely when your conflicts are spatially separated across the document.
Related
- Algorithm Selection for Merge
- Implementing Last-Write-Wins in CouchDB
- Implementing Field-Union Merge in CouchDB
- Implementing CRDT Counters in CouchDB
Part of: Algorithm Selection for Merge