Merging Field Audit Trails into the Master Database
A synced batch of device edits is worthless to an archive if you cannot say who recorded each feature, when, and on which device. The moment field GeoPackages are flattened into a master table with a blind INSERT, provenance is gone and the data-quality lineage a heritage archive requires becomes a guess. This guide implements the last leg of the pipeline: appending reconciled device edits into a master PostGIS store with editor, timestamp, and provenance columns, capturing every change in a trigger-based audit log, and reconciling device feature IDs against the authoritative UUID so nothing collides or overwrites. It is part of Participatory GIS and Mobile Field Sync.
Context & When to Use
Use this pattern whenever field edits from more than one device converge into a shared archive and you must preserve an audit trail for funders, planning authorities, or a national heritage register. The design principle is never mutate the master directly from a device file. Load device edits into a staging table first, stamp them with provenance, then upsert into the master on the UUID sync key with INSERT ... ON CONFLICT. Reconciliation is entirely about identity: the device’s local GeoPackage fid is meaningless in the master (two devices both start at fid = 1), so the UUID minted at capture is the only key you join on. The performance cost of the audit trigger is small — a row per change — but its value is total, since it is the difference between an archive you can defend and one you cannot. The choice of GeoPackage at the field edge and PostGIS at the archive is examined in comparing GeoPackage and PostGIS for field-to-archive handoff.
Implementation
First, the master table, an audit log, and the trigger that records every insert, update, and delete.
-- PostgreSQL 16 / PostGIS 3.4
CREATE TABLE finds_master (
fid bigserial PRIMARY KEY,
uuid uuid NOT NULL UNIQUE, -- device sync key, join on this
site_id text NOT NULL,
feature_type text,
collector_id text, -- provenance: who
device_id text, -- provenance: which device
recorded_at timestamptz, -- provenance: when captured
synced_at timestamptz DEFAULT now(), -- when it entered the archive
geom geometry(Point, 27700) -- substitute your site's EPSG
);
CREATE TABLE finds_audit (
audit_id bigserial PRIMARY KEY,
uuid uuid,
action text, -- INSERT / UPDATE / DELETE
changed_by text, -- session user performing the merge
changed_at timestamptz DEFAULT now(),
old_row jsonb,
new_row jsonb
);
CREATE OR REPLACE FUNCTION log_finds_change() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'DELETE') THEN
INSERT INTO finds_audit(uuid, action, changed_by, old_row)
VALUES (OLD.uuid, TG_OP, current_user, to_jsonb(OLD));
RETURN OLD;
ELSE
INSERT INTO finds_audit(uuid, action, changed_by, old_row, new_row)
VALUES (NEW.uuid, TG_OP, current_user,
CASE WHEN TG_OP = 'UPDATE' THEN to_jsonb(OLD) ELSE NULL END,
to_jsonb(NEW));
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER finds_audit_trg
AFTER INSERT OR UPDATE OR DELETE ON finds_master
FOR EACH ROW EXECUTE FUNCTION log_finds_change();
Now the Python side loads a device GeoPackage into a staging table and runs a provenance-stamping upsert. The audit trigger fires automatically on every affected master row.
# requirements.txt
# psycopg==3.1.18
# geopandas==0.14.3
# GDAL/ogr2ogr==3.8.4 (system binary)
# psycopg 3.1.18 / PostGIS 3.4
import subprocess
import psycopg
DSN = "postgresql://[email protected]:5432/heritage"
DEVICE_GPKG = "/data/sync/tablet_07/survey.gpkg"
DEVICE_ID = "tablet_07"
def stage_device_gpkg(gpkg=DEVICE_GPKG, dsn=DSN):
# push the device layer into a transient staging table via ogr2ogr
subprocess.run([
"ogr2ogr", "-f", "PostgreSQL",
f"PG:{dsn}", gpkg,
"finds", # source layer in the GeoPackage
"-nln", "finds_staging", # target staging table
"-overwrite",
"-t_srs", "EPSG:27700", # substitute your site's EPSG
"-lco", "GEOMETRY_NAME=geom",
], check=True)
def merge_staging(dsn=DSN, device_id=DEVICE_ID):
with psycopg.connect(dsn, autocommit=False) as conn:
with conn.cursor() as cur:
# upsert on the UUID sync key; device fid is deliberately ignored
cur.execute("""
INSERT INTO finds_master
(uuid, site_id, feature_type, collector_id,
device_id, recorded_at, geom)
SELECT uuid, site_id, feature_type, collector_id,
%(device_id)s, recorded_at, geom
FROM finds_staging
ON CONFLICT (uuid) DO UPDATE SET
feature_type = EXCLUDED.feature_type,
collector_id = EXCLUDED.collector_id,
device_id = EXCLUDED.device_id,
recorded_at = EXCLUDED.recorded_at,
geom = EXCLUDED.geom,
synced_at = now()
-- only apply the device edit if it is genuinely newer
WHERE EXCLUDED.recorded_at >= finds_master.recorded_at;
""", {"device_id": device_id})
merged = cur.rowcount
cur.execute("DROP TABLE finds_staging;")
conn.commit()
print(f"Merged {merged} device edits from {device_id}")
return merged
if __name__ == "__main__":
stage_device_gpkg()
merge_staging()
The WHERE EXCLUDED.recorded_at >= finds_master.recorded_at guard is the reconciliation safety net: a stale device that re-syncs old edits cannot clobber a newer correction already in the archive.
Verification
Confirm the merge stamped provenance on every row and that the audit log captured the batch:
-- PostgreSQL 16 / PostGIS 3.4
-- no archived feature may lack originator or capture time
SELECT count(*) AS ungoverned
FROM finds_master
WHERE collector_id IS NULL OR recorded_at IS NULL;
-- the audit log should hold one row per feature touched by this sync
SELECT action, count(*)
FROM finds_audit
WHERE changed_at > now() - interval '5 minutes'
GROUP BY action;
Expected: ungoverned returns 0, and the audit query lists INSERT and/or UPDATE counts matching the merge’s reported rowcount. A non-zero ungoverned count means a device form omitted a mandatory provenance field — quarantine those UUIDs rather than archiving them.
Common Errors & Fixes
duplicate key value violates unique constraint "finds_master_uuid_key"— the upsert usedINSERTwithoutON CONFLICT, or two staging rows share a UUID (a device that duplicated a feature). Deduplicate the staging table onuuid(keep the latestrecorded_at) before merging.ERROR: Geometry SRID (0) does not match column SRID (27700)—ogr2ogrloaded geometries without a CRS because the device GeoPackage layer had none assigned. Re-run with-a_srs EPSG:27700(assign) or-t_srs(reproject) so the staging geometry carries the master SRID.- Audit log stays empty after a merge — the trigger was created but the merge ran as a superuser session with triggers disabled, or
ALTER TABLE ... DISABLE TRIGGERis still in effect from a bulk load. Re-enable withALTER TABLE finds_master ENABLE TRIGGER finds_audit_trg;and re-run.
Related
- Participatory GIS and Mobile Field Sync — the section overview and full field-to-archive loop.
- Resolving Mergin Maps Delta-Sync Conflicts — reconciling conflicting edits before they reach this merge.
- Comparing GeoPackage and PostGIS for Field-to-Archive Handoff — why the archive lives in PostGIS while the field edge is GeoPackage.
- Packaging Offline Field Projects with QFieldSync — where the UUID sync key is first introduced to the device.