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.

Mergin Maps conflict resolution flow A pull and push cycle either syncs cleanly or produces a conflict file that is resolved and re-pushed. Delta sync and conflict handling pull rebase local push upload delta clean sync no conflict file *_conflict_* resolve + re-push merged master on server

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
Field note. A Mergin conflict file is not corruption and must never be deleted on sight — it is the only surviving record of an edit the recorder genuinely made in the trench. Rename it to .resolved and keep it with the project archive so the decision (whose edit won, and when) stays reconstructable. Deleting conflict files is how a season's worth of context measurements silently vanishes between the field and the archive.

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 previous push_project did not release the server lock (often a dropped connection mid-upload). Wait for the server-side transaction to time out, or call mc.remove_project_lock(), then re-pull before retrying.
  • GeoDiffLibError: rebase not possible, conflicting changes — the local base is too far behind and geodiff cannot 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 appearsgeodiff recorded 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.

Part of Heritage GIS Architecture & Fundamentals.