Resolving Mergin Maps Delta-Sync Conflicts
Two recorders open the same excavation unit polygon on two tablets, both adjust its edge, and both sync. Mergin Maps cannot know which edit is authoritative, so it accepts the first push, and when the second device pulls, geodiff writes the losing change into a conflict file sitting next to the GeoPackage. If nobody processes those files they quietly accumulate, and the “missing” edits a collector swears they made are hiding in a *_conflict_* copy that never reached the archive. This guide shows how to drive Mergin sync from Python with mergin-client, detect conflict files reliably, and apply a deterministic resolution strategy. It is part of Participatory GIS and Mobile Field Sync.
Context & When to Use
Mergin Maps suits multi-team, multi-device projects where a hosted service brokers sync and you want change-level history rather than whole-file copies. Its conflict model is optimistic: concurrent edits to different features merge automatically through geodiff; concurrent edits to the same feature’s same attribute produce a conflict file. That means conflict frequency scales with how often two recorders touch the same feature — high for shared boundary polygons, near zero for independent find points. Decide your resolution policy up front. “Server wins” is safe for reference layers a volunteer should not have altered; “field wins” suits primary observations the recorder is authoritative on; a per-column merge is the most faithful but demands you inspect each conflict. Automate the mechanical cases and escalate genuine disagreements to the archive maintainer rather than letting the client pick blindly. For where the reconciled edits then land, see merging field audit trails into the master database.
Implementation
The script authenticates, syncs a local project copy, and scans for the conflict files geodiff produces. Each conflict file is itself a valid GeoPackage holding the rejected version of the feature, so resolution is a matter of reading both, applying your policy, and writing the decision back.
# requirements.txt
# mergin-client==0.9.2
# pygeodiff==2.0.2
# geopandas==0.14.3
# mergin-client 0.9.2 / pygeodiff 2.0.2
import os
import glob
import geopandas as gpd
from mergin import MerginClient, ClientError, LoginError
PROJECT_DIR = "/data/mergin/heritage_survey"
PROJECT_NAME = "yourworkspace/heritage_survey"
SYNC_LAYER = "excavation_units" # the layer prone to shared-edge conflicts
UUID_COL = "uuid" # stable sync key, not the GeoPackage fid
def sync_project(url="https://app.merginmaps.com"):
try:
mc = MerginClient(url, login=os.environ["MERGIN_USER"],
password=os.environ["MERGIN_PASSWORD"])
except LoginError as e:
raise SystemExit(f"Mergin authentication failed: {e}")
if not os.path.exists(os.path.join(PROJECT_DIR, ".mergin")):
mc.download_project(PROJECT_NAME, PROJECT_DIR)
# pull remote changes first (rebases local edits), then push local delta
mc.pull_project(PROJECT_DIR)
try:
mc.push_project(PROJECT_DIR)
except ClientError as e:
# a push can be rejected if the server advanced between pull and push
print(f"Push rejected, re-pulling before retry: {e}")
mc.pull_project(PROJECT_DIR)
mc.push_project(PROJECT_DIR)
return mc
def find_conflicts(project_dir=PROJECT_DIR):
# geodiff names conflict copies '<user>_conflict_copy_<n>.gpkg' beside the data
pattern = os.path.join(project_dir, "*_conflict_*")
conflicts = sorted(glob.glob(pattern))
if conflicts:
print(f"{len(conflicts)} conflict file(s) awaiting resolution:")
for c in conflicts:
print(f" {os.path.basename(c)}")
return conflicts
def resolve_conflict(conflict_gpkg, master_gpkg, policy="field_wins"):
"""
Apply a deterministic resolution policy per feature.
field_wins : the device's rejected edit replaces the server version
server_wins: discard the device edit (just archive the conflict file)
merge_attrs: keep server geometry, take non-null device attributes
"""
losing = gpd.read_file(conflict_gpkg, layer=SYNC_LAYER)
master = gpd.read_file(master_gpkg, layer=SYNC_LAYER)
master = master.set_index(UUID_COL)
for _, row in losing.iterrows():
key = row[UUID_COL]
if key not in master.index:
continue # feature was deleted on server; escalate, do not resurrect
if policy == "field_wins":
master.loc[key, "geometry"] = row.geometry
for col in losing.columns:
if col not in (UUID_COL, "geometry"):
master.loc[key, col] = row[col]
elif policy == "merge_attrs":
for col in losing.columns:
if col not in (UUID_COL, "geometry") and row[col] is not None:
master.loc[key, col] = row[col]
# server_wins: no action, the conflict file is simply archived
master.reset_index().to_file(master_gpkg, layer=SYNC_LAYER, driver="GPKG")
# move the processed conflict file into an audit archive, never delete silently
archive = conflict_gpkg + ".resolved"
os.rename(conflict_gpkg, archive)
return archive
if __name__ == "__main__":
sync_project()
master = os.path.join(PROJECT_DIR, "survey.gpkg")
for cf in find_conflicts():
resolve_conflict(cf, master, policy="field_wins")
sync_project() # push the resolutions back
To inspect exactly what changed in a delta before you resolve it, dump the geodiff change set as JSON rather than guessing from the geometry:
# pygeodiff 2.0.2 — human-readable summary of a single sync delta
from pygeodiff import GeoDiff
gd = GeoDiff()
gd.list_changes("/data/mergin/heritage_survey/.mergin/base.gpkg-diff",
"/tmp/changes.json") # writes per-row inserts/updates/deletes
Verification
After resolution and re-sync, no unresolved conflict files should remain and the master feature count should reflect every accepted edit:
# should print nothing when all conflicts are resolved
ls /data/mergin/heritage_survey/*_conflict_* 2>/dev/null
# archived decisions remain for audit
ls /data/mergin/heritage_survey/*.resolved
Then assert the resolved geometry actually changed on the server copy:
import geopandas as gpd
g = gpd.read_file("/data/mergin/heritage_survey/survey.gpkg", layer="excavation_units")
assert g["uuid"].is_unique, "duplicate sync keys after merge — resolution reintroduced a clash"
print(f"{len(g)} units, all uuids unique")
Expected output is a single count line and no assertion error. A raised assertion means two features share a UUID, usually because a field_wins resolution re-inserted a feature the server had already merged.
Common Errors & Fixes
ClientError: Another sync process is running— a previouspush_projectdid not release the server lock (often a dropped connection mid-upload). Wait for the server-side transaction to time out, or callmc.remove_project_lock(), then re-pull before retrying.GeoDiffLibError: rebase not possible, conflicting changes— the local base is too far behind andgeodiffcannot replay cleanly. Pull into a fresh checkout, export your local edits as a diff, and re-apply them against the current server head.- Conflict file has zero features but still appears —
geodiffrecorded a delete-vs-update conflict; the device updated a feature the server deleted. Do not resurrect it automatically — flag the UUID for the archive maintainer, because a deleted excavation unit usually means it was merged or renumbered on purpose.
Related
- Participatory GIS and Mobile Field Sync — the section overview and full field-to-archive loop.
- Packaging Offline Field Projects with QFieldSync — the QField packaging alternative to hosted Mergin sync.
- Merging Field Audit Trails into the Master Database — appending resolved edits into PostGIS with provenance.
- Comparing GeoPackage and PostGIS for Field-to-Archive Handoff — why delta sync rides on GeoPackage at the field stage.